add pytest
This commit is contained in:
parent
068a1b38f9
commit
a9b0fd34a2
21 changed files with 2169 additions and 129 deletions
143
.github/workflows/backend-tests.yml
vendored
Normal file
143
.github/workflows/backend-tests.yml
vendored
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
name: Backend Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- '.github/workflows/backend-tests.yml'
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- '.github/workflows/backend-tests.yml'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.10', '3.11', '3.12']
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
env:
|
||||
POSTGRES_USER: test_user
|
||||
POSTGRES_PASSWORD: test_password
|
||||
POSTGRES_DB: test_db
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
cache-dependency-path: 'backend/requirements/*.txt'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./backend
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements/base.txt
|
||||
pip install -r requirements/dev.txt
|
||||
|
||||
- name: Lint with flake8 (if installed)
|
||||
working-directory: ./backend
|
||||
run: |
|
||||
pip install flake8
|
||||
# Stop the build if there are Python syntax errors or undefined names
|
||||
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
# Exit-zero treats all errors as warnings
|
||||
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run tests with pytest
|
||||
working-directory: ./backend
|
||||
env:
|
||||
DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db
|
||||
CELERY_BROKER_URL: redis://localhost:6379/0
|
||||
CELERY_RESULT_BACKEND: redis://localhost:6379/0
|
||||
SECRET_KEY: test-secret-key
|
||||
JWT_SECRET_KEY: test-jwt-secret-key
|
||||
FLASK_ENV: testing
|
||||
run: |
|
||||
pytest --cov=app --cov-report=xml --cov-report=html --cov-report=term
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./backend/coverage.xml
|
||||
flags: backend
|
||||
name: codecov-umbrella
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Upload coverage HTML as artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage-report-python-${{ matrix.python-version }}
|
||||
path: backend/htmlcov/
|
||||
retention-days: 7
|
||||
|
||||
- name: Check coverage thresholds
|
||||
working-directory: ./backend
|
||||
run: |
|
||||
coverage report --fail-under=80
|
||||
|
||||
security-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./backend
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install bandit safety
|
||||
|
||||
- name: Run Bandit security linter
|
||||
working-directory: ./backend
|
||||
run: |
|
||||
bandit -r app -f json -o bandit-report.json || true
|
||||
continue-on-error: true
|
||||
|
||||
- name: Check for known security vulnerabilities
|
||||
working-directory: ./backend
|
||||
run: |
|
||||
safety check --json --output safety-report.json || true
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload security reports
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: security-reports
|
||||
path: |
|
||||
backend/bandit-report.json
|
||||
backend/safety-report.json
|
||||
65
.pre-commit-config.yaml
Normal file
65
.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.5.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
exclude: ^.+\.md$
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-added-large-files
|
||||
args: ['--maxkb=1000']
|
||||
- id: check-json
|
||||
- id: check-toml
|
||||
- id: check-merge-conflict
|
||||
- id: debug-statements
|
||||
language: python
|
||||
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 23.12.1
|
||||
hooks:
|
||||
- id: black
|
||||
language_version: python3.11
|
||||
args: ['--line-length=100']
|
||||
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.13.2
|
||||
hooks:
|
||||
- id: isort
|
||||
args: ['--profile=black', '--line-length=100']
|
||||
|
||||
- repo: https://github.com/pycqa/flake8
|
||||
rev: 7.0.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
args: ['--max-line-length=100', '--extend-ignore=E203,W503']
|
||||
additional_dependencies: [
|
||||
flake8-docstrings,
|
||||
flake8-bugbear,
|
||||
flake8-comprehensions,
|
||||
]
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pytest
|
||||
name: pytest
|
||||
entry: pytest
|
||||
language: system
|
||||
pass_filenames: false
|
||||
args: ['backend/', '-v', '--tb=short']
|
||||
always_run: true
|
||||
|
||||
- id: type-check
|
||||
name: mypy type check
|
||||
entry: mypy
|
||||
language: system
|
||||
pass_filenames: false
|
||||
args: ['backend/app/']
|
||||
always_run: false
|
||||
|
||||
- id: security-check
|
||||
name: bandit security check
|
||||
entry: bandit
|
||||
language: system
|
||||
pass_filenames: false
|
||||
args: ['-r', 'backend/app/', '-ll']
|
||||
always_run: false
|
||||
42
Makefile
42
Makefile
|
|
@ -67,6 +67,48 @@ test: ## Run all tests
|
|||
test-backend: ## Run backend tests only
|
||||
cd backend && . venv/bin/activate && pytest
|
||||
|
||||
test-backend-cov: ## Run backend tests with coverage
|
||||
cd backend && . venv/bin/activate && pytest --cov=app --cov-report=html --cov-report=term
|
||||
|
||||
test-backend-verbose: ## Run backend tests with verbose output
|
||||
cd backend && . venv/bin/activate && pytest -v
|
||||
|
||||
test-backend-unit: ## Run backend unit tests only
|
||||
cd backend && . venv/bin/activate && pytest -m unit
|
||||
|
||||
test-backend-integration: ## Run backend integration tests only
|
||||
cd backend && . venv/bin/activate && pytest -m integration
|
||||
|
||||
test-backend-auth: ## Run backend authentication tests only
|
||||
cd backend && . venv/bin/activate && pytest -m auth
|
||||
|
||||
test-backend-product: ## Run backend product tests only
|
||||
cd backend && . venv/bin/activate && pytest -m product
|
||||
|
||||
test-backend-order: ## Run backend order tests only
|
||||
cd backend && . venv/bin/activate && pytest -m order
|
||||
|
||||
test-backend-watch: ## Run backend tests in watch mode (auto-rerun on changes)
|
||||
cd backend && . venv/bin/activate && pip install pytest-watch && pytest-watch
|
||||
|
||||
test-backend-parallel: ## Run backend tests in parallel (faster)
|
||||
cd backend && . venv/bin/activate && pip install pytest-xdist && pytest -n auto
|
||||
|
||||
test-backend-coverage-report: ## Open backend coverage report in browser
|
||||
cd backend && . venv/bin/activate && pytest --cov=app --cov-report=html && python -m webbrowser htmlcov/index.html
|
||||
|
||||
test-backend-failed: ## Re-run only failed backend tests
|
||||
cd backend && . venv/bin/activate && pytest --lf
|
||||
|
||||
test-backend-last-failed: ## Run the tests that failed in the last run
|
||||
cd backend && . venv/bin/activate && pytest --lf
|
||||
|
||||
test-backend-specific: ## Run specific backend test (usage: make test-backend-specific TEST=test_models.py)
|
||||
cd backend && . venv/bin/activate && pytest tests/$(TEST)
|
||||
|
||||
test-backend-marker: ## Run backend tests by marker (usage: make test-backend-marker MARKER=auth)
|
||||
cd backend && . venv/bin/activate && pytest -m $(MARKER)
|
||||
|
||||
test-frontend: ## Run frontend tests only
|
||||
cd frontend && npm test
|
||||
|
||||
|
|
|
|||
23
backend/.coveragerc
Normal file
23
backend/.coveragerc
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
[run]
|
||||
source = app
|
||||
omit =
|
||||
*/tests/*
|
||||
*/migrations/*
|
||||
*/__pycache__/*
|
||||
*/venv/*
|
||||
*/instance/*
|
||||
app/__init__.py
|
||||
|
||||
[report]
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
def __repr__
|
||||
raise NotImplementedError
|
||||
if __name__ == .__main__.:
|
||||
if TYPE_CHECKING:
|
||||
@abstractmethod
|
||||
pass
|
||||
precision = 2
|
||||
|
||||
[html]
|
||||
directory = htmlcov
|
||||
|
|
@ -50,10 +50,17 @@ def create_app(config_name=None):
|
|||
# Global error handlers
|
||||
@app.errorhandler(404)
|
||||
def not_found(error):
|
||||
print(f"404 Error: {error}")
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(error):
|
||||
print(f"500 Error: {error}")
|
||||
return jsonify({"error": "Internal server error"}), 500
|
||||
|
||||
@app.errorhandler(422)
|
||||
def validation_error(error):
|
||||
print(f"422 Error: {error}")
|
||||
return jsonify({"error": "Validation error"}), 422
|
||||
|
||||
return app
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ class Config:
|
|||
"""Base configuration"""
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY") or "dev-secret-key-change-in-production"
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY") or "jwt-secret-key-change-in-production"
|
||||
JWT_SECRET_KEY = os.environ["JWT_SECRET_KEY"]
|
||||
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)
|
||||
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30)
|
||||
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "*")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from datetime import datetime
|
||||
from datetime import datetime, UTC
|
||||
from app import db
|
||||
|
||||
|
||||
|
|
@ -11,8 +11,8 @@ class Order(db.Model):
|
|||
status = db.Column(db.String(20), default="pending", index=True)
|
||||
total_amount = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
shipping_address = db.Column(db.Text)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
|
||||
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
|
||||
# Relationships
|
||||
user = db.relationship("User", back_populates="orders")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from datetime import datetime
|
||||
from datetime import datetime, UTC
|
||||
from app import db
|
||||
|
||||
|
||||
|
|
@ -13,8 +13,8 @@ class Product(db.Model):
|
|||
stock = db.Column(db.Integer, default=0)
|
||||
image_url = db.Column(db.String(500))
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
|
||||
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
|
||||
# Relationships
|
||||
order_items = db.relationship("OrderItem", back_populates="product", lazy="dynamic")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from datetime import datetime
|
||||
from datetime import datetime, UTC
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from app import db
|
||||
|
||||
|
|
@ -15,8 +15,8 @@ class User(db.Model):
|
|||
last_name = db.Column(db.String(50))
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
is_admin = db.Column(db.Boolean, default=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC))
|
||||
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
|
||||
# Relationships
|
||||
orders = db.relationship("Order", back_populates="user", lazy="dynamic")
|
||||
|
|
|
|||
|
|
@ -54,8 +54,8 @@ def login():
|
|||
if not user.is_active:
|
||||
return jsonify({"error": "Account is inactive"}), 401
|
||||
|
||||
access_token = create_access_token(identity=user.id)
|
||||
refresh_token = create_refresh_token(identity=user.id)
|
||||
access_token = create_access_token(identity=str(user.id))
|
||||
refresh_token = create_refresh_token(identity=str(user.id))
|
||||
|
||||
return jsonify({
|
||||
"user": user.to_dict(),
|
||||
|
|
@ -68,8 +68,8 @@ def login():
|
|||
@jwt_required()
|
||||
def get_current_user():
|
||||
"""Get current user"""
|
||||
user_id = get_jwt_identity()
|
||||
user = User.query.get(user_id)
|
||||
user_id = int(get_jwt_identity())
|
||||
user = db.session.get(User, user_id)
|
||||
|
||||
if not user:
|
||||
return jsonify({"error": "User not found"}), 404
|
||||
|
|
@ -93,7 +93,9 @@ def get_products():
|
|||
@api_bp.route("/products/<int:product_id>", methods=["GET"])
|
||||
def get_product(product_id):
|
||||
"""Get a single product"""
|
||||
product = Product.query.get_or_404(product_id)
|
||||
product = db.session.get(Product, product_id)
|
||||
if not product:
|
||||
return jsonify({"error": "Product not found"}), 404
|
||||
return jsonify(product.to_dict()), 200
|
||||
|
||||
|
||||
|
|
@ -101,8 +103,8 @@ def get_product(product_id):
|
|||
@jwt_required()
|
||||
def create_product():
|
||||
"""Create a new product (admin only)"""
|
||||
user_id = get_jwt_identity()
|
||||
user = User.query.get(user_id)
|
||||
user_id = int(get_jwt_identity())
|
||||
user = db.session.get(User, user_id)
|
||||
|
||||
if not user or not user.is_admin:
|
||||
return jsonify({"error": "Admin access required"}), 403
|
||||
|
|
@ -116,8 +118,7 @@ def create_product():
|
|||
description=product_data.description,
|
||||
price=product_data.price,
|
||||
stock=product_data.stock,
|
||||
image_url=product_data.image_url,
|
||||
category=product_data.category
|
||||
image_url=product_data.image_url
|
||||
)
|
||||
|
||||
db.session.add(product)
|
||||
|
|
@ -128,6 +129,7 @@ def create_product():
|
|||
return jsonify(response.model_dump()), 201
|
||||
|
||||
except ValidationError as e:
|
||||
print(f"Pydantic Validation Error: {e.errors()}")
|
||||
return jsonify({"error": "Validation error", "details": e.errors()}), 400
|
||||
|
||||
|
||||
|
|
@ -135,13 +137,16 @@ def create_product():
|
|||
@jwt_required()
|
||||
def update_product(product_id):
|
||||
"""Update a product (admin only)"""
|
||||
user_id = get_jwt_identity()
|
||||
user = User.query.get(user_id)
|
||||
user_id = int(get_jwt_identity())
|
||||
user = db.session.get(User, user_id)
|
||||
|
||||
if not user or not user.is_admin:
|
||||
return jsonify({"error": "Admin access required"}), 403
|
||||
|
||||
product = Product.query.get_or_404(product_id)
|
||||
product = db.session.get(Product, product_id)
|
||||
if not product:
|
||||
return jsonify({"error": "Product not found"}), 404
|
||||
|
||||
data = request.get_json()
|
||||
|
||||
product.name = data.get("name", product.name)
|
||||
|
|
@ -149,7 +154,6 @@ def update_product(product_id):
|
|||
product.price = data.get("price", product.price)
|
||||
product.stock = data.get("stock", product.stock)
|
||||
product.image_url = data.get("image_url", product.image_url)
|
||||
product.category = data.get("category", product.category)
|
||||
product.is_active = data.get("is_active", product.is_active)
|
||||
|
||||
db.session.commit()
|
||||
|
|
@ -161,13 +165,16 @@ def update_product(product_id):
|
|||
@jwt_required()
|
||||
def delete_product(product_id):
|
||||
"""Delete a product (admin only)"""
|
||||
user_id = get_jwt_identity()
|
||||
user = User.query.get(user_id)
|
||||
user_id = int(get_jwt_identity())
|
||||
user = db.session.get(User, user_id)
|
||||
|
||||
if not user or not user.is_admin:
|
||||
return jsonify({"error": "Admin access required"}), 403
|
||||
|
||||
product = Product.query.get_or_404(product_id)
|
||||
product = db.session.get(Product, product_id)
|
||||
if not product:
|
||||
return jsonify({"error": "Product not found"}), 404
|
||||
|
||||
db.session.delete(product)
|
||||
db.session.commit()
|
||||
|
||||
|
|
@ -179,7 +186,7 @@ def delete_product(product_id):
|
|||
@jwt_required()
|
||||
def get_orders():
|
||||
"""Get all orders for current user"""
|
||||
user_id = get_jwt_identity()
|
||||
user_id = int(get_jwt_identity())
|
||||
orders = Order.query.filter_by(user_id=user_id).all()
|
||||
return jsonify([order.to_dict() for order in orders]), 200
|
||||
|
||||
|
|
@ -188,7 +195,7 @@ def get_orders():
|
|||
@jwt_required()
|
||||
def create_order():
|
||||
"""Create a new order"""
|
||||
user_id = get_jwt_identity()
|
||||
user_id = int(get_jwt_identity())
|
||||
data = request.get_json()
|
||||
|
||||
if not data or not data.get("items"):
|
||||
|
|
@ -198,7 +205,7 @@ def create_order():
|
|||
order_items = []
|
||||
|
||||
for item_data in data["items"]:
|
||||
product = Product.query.get(item_data["product_id"])
|
||||
product = db.session.get(Product, item_data["product_id"])
|
||||
if not product:
|
||||
return jsonify({"error": f'Product {item_data["product_id"]} not found'}), 404
|
||||
if product.stock < item_data["quantity"]:
|
||||
|
|
@ -215,8 +222,7 @@ def create_order():
|
|||
order = Order(
|
||||
user_id=user_id,
|
||||
total_amount=total_amount,
|
||||
shipping_address=data.get("shipping_address"),
|
||||
notes=data.get("notes")
|
||||
shipping_address=data.get("shipping_address")
|
||||
)
|
||||
|
||||
db.session.add(order)
|
||||
|
|
@ -241,11 +247,13 @@ def create_order():
|
|||
@jwt_required()
|
||||
def get_order(order_id):
|
||||
"""Get a single order"""
|
||||
user_id = get_jwt_identity()
|
||||
order = Order.query.get_or_404(order_id)
|
||||
user_id = int(get_jwt_identity())
|
||||
order = db.session.get(Order, order_id)
|
||||
if not order:
|
||||
return jsonify({"error": "Order not found"}), 404
|
||||
|
||||
if order.user_id != user_id:
|
||||
user = User.query.get(user_id)
|
||||
user = db.session.get(User, user_id)
|
||||
if not user or not user.is_admin:
|
||||
return jsonify({"error": "Access denied"}), 403
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"""Pydantic schemas for Product model"""
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, Field, field_validator, ConfigDict
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
|
@ -7,24 +7,23 @@ from typing import Optional
|
|||
|
||||
class ProductCreateRequest(BaseModel):
|
||||
"""Schema for creating a new product"""
|
||||
name: str = Field(..., min_length=1, max_length=200, description="Product name")
|
||||
description: Optional[str] = Field(None, description="Product description")
|
||||
price: Decimal = Field(..., gt=0, description="Product price (must be greater than 0)")
|
||||
stock: int = Field(default=0, ge=0, description="Product stock quantity")
|
||||
image_url: Optional[str] = Field(None, max_length=500, description="Product image URL")
|
||||
category: Optional[str] = Field(None, description="Product category")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"name": "Handcrafted Wooden Bowl",
|
||||
"description": "A beautiful handcrafted bowl made from oak",
|
||||
"price": 45.99,
|
||||
"stock": 10,
|
||||
"image_url": "https://example.com/bowl.jpg",
|
||||
"category": "Woodwork"
|
||||
"image_url": "https://example.com/bowl.jpg"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=200, description="Product name")
|
||||
description: Optional[str] = Field(None, description="Product description")
|
||||
price: Decimal = Field(..., gt=0, description="Product price (must be greater than 0)")
|
||||
stock: int = Field(default=0, ge=0, description="Product stock quantity")
|
||||
image_url: Optional[str] = Field(None, max_length=500, description="Product image URL")
|
||||
|
||||
@field_validator("price")
|
||||
@classmethod
|
||||
|
|
@ -37,19 +36,9 @@ class ProductCreateRequest(BaseModel):
|
|||
|
||||
class ProductResponse(BaseModel):
|
||||
"""Schema for product response"""
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
price: float
|
||||
stock: int
|
||||
image_url: Optional[str] = None
|
||||
is_active: bool
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
json_schema_extra = {
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"id": 1,
|
||||
"name": "Handcrafted Wooden Bowl",
|
||||
|
|
@ -62,3 +51,14 @@ class ProductResponse(BaseModel):
|
|||
"updated_at": "2024-01-15T10:30:00"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
price: float
|
||||
stock: int
|
||||
image_url: Optional[str] = None
|
||||
is_active: bool
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
import os
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
class Config:
|
||||
"""Base configuration"""
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY') or 'jwt-secret-key-change-in-production'
|
||||
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)
|
||||
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30)
|
||||
|
||||
# Celery Configuration
|
||||
CELERY = {
|
||||
"broker_url": os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/0"),
|
||||
"result_backend": os.environ.get("CELERY_RESULT_BACKEND", "redis://localhost:6379/0"),
|
||||
"task_serializer": "json",
|
||||
"result_serializer": "json",
|
||||
"accept_content": ["json"],
|
||||
"timezone": "UTC",
|
||||
"enable_utc": True,
|
||||
"task_track_started": True,
|
||||
"task_time_limit": 30 * 60, # 30 minutes
|
||||
"task_soft_time_limit": 25 * 60, # 25 minutes
|
||||
"task_acks_late": True, # Acknowledge after task completion
|
||||
"task_reject_on_worker_lost": True, # Re-queue if worker dies
|
||||
"worker_prefetch_multiplier": 1, # Process one task at a time
|
||||
"worker_max_tasks_per_child": 100, # Restart worker after 100 tasks
|
||||
"broker_connection_retry_on_startup": True,
|
||||
"broker_connection_max_retries": 5,
|
||||
"result_expires": 3600, # Results expire in 1 hour
|
||||
"task_default_queue": "default",
|
||||
"task_default_exchange": "default",
|
||||
"task_default_routing_key": "default",
|
||||
}
|
||||
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
"""Development configuration"""
|
||||
DEBUG = True
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or \
|
||||
'sqlite:///dev.db'
|
||||
|
||||
|
||||
class TestingConfig(Config):
|
||||
"""Testing configuration"""
|
||||
TESTING = True
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or \
|
||||
'sqlite:///test.db'
|
||||
WTF_CSRF_ENABLED = False
|
||||
|
||||
|
||||
class ProductionConfig(Config):
|
||||
"""Production configuration"""
|
||||
DEBUG = False
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
|
||||
'postgresql://user:password@localhost/proddb'
|
||||
|
||||
# Security headers
|
||||
SESSION_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||
|
||||
|
||||
config_by_name = {
|
||||
'dev': DevelopmentConfig,
|
||||
'test': TestingConfig,
|
||||
'prod': ProductionConfig
|
||||
}
|
||||
19
backend/pytest.ini
Normal file
19
backend/pytest.ini
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[pytest]
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
testpaths = tests
|
||||
addopts =
|
||||
-v
|
||||
--strict-markers
|
||||
--tb=short
|
||||
--cov=app
|
||||
--cov-report=term-missing
|
||||
--cov-report=html
|
||||
markers =
|
||||
slow: Tests that are slow to run
|
||||
integration: Integration tests
|
||||
unit: Unit tests
|
||||
auth: Authentication tests
|
||||
product: Product-related tests
|
||||
order: Order-related tests
|
||||
|
|
@ -9,3 +9,9 @@ Werkzeug==3.0.1
|
|||
SQLAlchemy==2.0.23
|
||||
celery[redis]==5.3.6
|
||||
pydantic==2.5.3
|
||||
pytest==7.4.3
|
||||
pytest-flask==1.3.0
|
||||
pytest-cov==4.1.0
|
||||
pytest-mock==3.12.0
|
||||
factory-boy==3.3.0
|
||||
faker==20.1.0
|
||||
|
|
|
|||
1
backend/tests/__init__.py
Normal file
1
backend/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Tests package for Flask application"""
|
||||
191
backend/tests/conftest.py
Normal file
191
backend/tests/conftest.py
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
"""Pytest configuration and fixtures"""
|
||||
import pytest
|
||||
import tempfile
|
||||
import os
|
||||
from faker import Faker
|
||||
|
||||
from app import create_app, db
|
||||
from app.models import User, Product, Order, OrderItem
|
||||
|
||||
fake = Faker()
|
||||
|
||||
|
||||
@pytest.fixture(scope='function')
|
||||
def app():
|
||||
"""Create application for testing with isolated database"""
|
||||
db_fd, db_path = tempfile.mkstemp()
|
||||
|
||||
app = create_app(config_name='test')
|
||||
app.config.update({
|
||||
'TESTING': True,
|
||||
'SQLALCHEMY_DATABASE_URI': f'sqlite:///{db_path}',
|
||||
'WTF_CSRF_ENABLED': False,
|
||||
'JWT_SECRET_KEY': 'test-secret-keytest-secret-keytest-secret-keytest-secret-keytest-secret-key',
|
||||
'SERVER_NAME': 'localhost.localdomain'
|
||||
})
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
yield app
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
|
||||
os.close(db_fd)
|
||||
os.unlink(db_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Test client for making requests"""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner(app):
|
||||
"""Test CLI runner"""
|
||||
return app.test_cli_runner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_session(app):
|
||||
"""Database session for tests"""
|
||||
with app.app_context():
|
||||
yield db.session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_user(db_session):
|
||||
"""Create an admin user for testing"""
|
||||
user = User(
|
||||
email=fake.email(),
|
||||
username=fake.user_name(),
|
||||
first_name=fake.first_name(),
|
||||
last_name=fake.last_name(),
|
||||
is_admin=True,
|
||||
is_active=True
|
||||
)
|
||||
user.set_password('password123')
|
||||
db_session.add(user)
|
||||
db_session.commit()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def regular_user(db_session):
|
||||
"""Create a regular user for testing"""
|
||||
user = User(
|
||||
email=fake.email(),
|
||||
username=fake.user_name(),
|
||||
first_name=fake.first_name(),
|
||||
last_name=fake.last_name(),
|
||||
is_admin=False,
|
||||
is_active=True
|
||||
)
|
||||
user.set_password('password123')
|
||||
db_session.add(user)
|
||||
db_session.commit()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def inactive_user(db_session):
|
||||
"""Create an inactive user for testing"""
|
||||
user = User(
|
||||
email=fake.email(),
|
||||
username=fake.user_name(),
|
||||
first_name=fake.first_name(),
|
||||
last_name=fake.last_name(),
|
||||
is_admin=False,
|
||||
is_active=False
|
||||
)
|
||||
user.set_password('password123')
|
||||
db_session.add(user)
|
||||
db_session.commit()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def product(db_session):
|
||||
"""Create a product for testing"""
|
||||
product = Product(
|
||||
name=fake.sentence(nb_words=4)[:-1], # Remove period
|
||||
description=fake.paragraph(),
|
||||
price=fake.pydecimal(left_digits=2, right_digits=2, positive=True),
|
||||
stock=fake.pyint(min_value=0, max_value=100),
|
||||
image_url=fake.url()
|
||||
)
|
||||
db_session.add(product)
|
||||
db_session.commit()
|
||||
return product
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def products(db_session):
|
||||
"""Create multiple products for testing"""
|
||||
products = []
|
||||
for _ in range(5):
|
||||
product = Product(
|
||||
name=fake.sentence(nb_words=4)[:-1],
|
||||
description=fake.paragraph(),
|
||||
price=fake.pydecimal(left_digits=2, right_digits=2, positive=True),
|
||||
stock=fake.pyint(min_value=0, max_value=100),
|
||||
image_url=fake.url()
|
||||
)
|
||||
db_session.add(product)
|
||||
products.append(product)
|
||||
db_session.commit()
|
||||
return products
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(client, regular_user):
|
||||
"""Get authentication headers for a regular user"""
|
||||
response = client.post('/api/auth/login', json={
|
||||
'email': regular_user.email,
|
||||
'password': 'password123'
|
||||
})
|
||||
data = response.get_json()
|
||||
token = data['access_token']
|
||||
print(f"Auth headers token for user {regular_user.email}: {token[:50]}...")
|
||||
return {'Authorization': f'Bearer {token}'}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_headers(client, admin_user):
|
||||
"""Get authentication headers for an admin user"""
|
||||
response = client.post('/api/auth/login', json={
|
||||
'email': admin_user.email,
|
||||
'password': 'password123'
|
||||
})
|
||||
data = response.get_json()
|
||||
token = data['access_token']
|
||||
print(f"Admin headers token for user {admin_user.email}: {token[:50]}...")
|
||||
return {'Authorization': f'Bearer {token}'}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def order(db_session, regular_user, products):
|
||||
"""Create an order for testing"""
|
||||
order = Order(
|
||||
user_id=regular_user.id,
|
||||
total_amount=0.0,
|
||||
shipping_address=fake.address()
|
||||
)
|
||||
db_session.add(order)
|
||||
db_session.flush()
|
||||
|
||||
total_amount = 0
|
||||
for i, product in enumerate(products[:2]):
|
||||
quantity = fake.pyint(min_value=1, max_value=5)
|
||||
order_item = OrderItem(
|
||||
order_id=order.id,
|
||||
product_id=product.id,
|
||||
quantity=quantity,
|
||||
price=product.price
|
||||
)
|
||||
total_amount += float(product.price) * quantity
|
||||
db_session.add(order_item)
|
||||
|
||||
order.total_amount = total_amount
|
||||
db_session.commit()
|
||||
return order
|
||||
208
backend/tests/test_models.py
Normal file
208
backend/tests/test_models.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
"""Test models"""
|
||||
import pytest
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
from app.models import User, Product, Order, OrderItem
|
||||
|
||||
|
||||
class TestUserModel:
|
||||
"""Test User model"""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_user_creation(self, db_session):
|
||||
"""Test creating a user"""
|
||||
user = User(
|
||||
email='test@example.com',
|
||||
username='testuser',
|
||||
first_name='Test',
|
||||
last_name='User',
|
||||
is_admin=False,
|
||||
is_active=True
|
||||
)
|
||||
user.set_password('password123')
|
||||
db_session.add(user)
|
||||
db_session.commit()
|
||||
|
||||
assert user.id is not None
|
||||
assert user.email == 'test@example.com'
|
||||
assert user.username == 'testuser'
|
||||
assert user.first_name == 'Test'
|
||||
assert user.last_name == 'User'
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_user_password_hashing(self, db_session):
|
||||
"""Test password hashing and verification"""
|
||||
user = User(email='test@example.com', username='testuser')
|
||||
user.set_password('password123')
|
||||
db_session.add(user)
|
||||
db_session.commit()
|
||||
|
||||
assert user.check_password('password123') is True
|
||||
assert user.check_password('wrongpassword') is False
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_user_to_dict(self, db_session):
|
||||
"""Test user serialization to dictionary"""
|
||||
user = User(
|
||||
email='test@example.com',
|
||||
username='testuser',
|
||||
first_name='Test',
|
||||
last_name='User'
|
||||
)
|
||||
user.set_password('password123')
|
||||
db_session.add(user)
|
||||
db_session.commit()
|
||||
|
||||
user_dict = user.to_dict()
|
||||
assert user_dict['email'] == 'test@example.com'
|
||||
assert user_dict['username'] == 'testuser'
|
||||
assert 'password' not in user_dict
|
||||
assert 'password_hash' not in user_dict
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_user_repr(self, db_session):
|
||||
"""Test user string representation"""
|
||||
user = User(email='test@example.com', username='testuser')
|
||||
user.set_password('password123')
|
||||
db_session.add(user)
|
||||
db_session.commit()
|
||||
|
||||
assert repr(user) == '<User testuser>'
|
||||
|
||||
|
||||
class TestProductModel:
|
||||
"""Test Product model"""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_product_creation(self, db_session):
|
||||
"""Test creating a product"""
|
||||
product = Product(
|
||||
name='Test Product',
|
||||
description='A test product',
|
||||
price=Decimal('99.99'),
|
||||
stock=10,
|
||||
image_url='https://example.com/product.jpg'
|
||||
)
|
||||
db_session.add(product)
|
||||
db_session.commit()
|
||||
|
||||
assert product.id is not None
|
||||
assert product.name == 'Test Product'
|
||||
assert product.price == Decimal('99.99')
|
||||
assert product.stock == 10
|
||||
assert product.is_active is True
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_product_to_dict(self, db_session):
|
||||
"""Test product serialization to dictionary"""
|
||||
product = Product(
|
||||
name='Test Product',
|
||||
description='A test product',
|
||||
price=Decimal('99.99'),
|
||||
stock=10
|
||||
)
|
||||
db_session.add(product)
|
||||
db_session.commit()
|
||||
|
||||
product_dict = product.to_dict()
|
||||
assert product_dict['name'] == 'Test Product'
|
||||
assert product_dict['price'] == 99.99
|
||||
assert isinstance(product_dict['created_at'], str)
|
||||
assert isinstance(product_dict['updated_at'], str)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_product_defaults(self, db_session):
|
||||
"""Test product default values"""
|
||||
product = Product(
|
||||
name='Test Product',
|
||||
price=Decimal('9.99')
|
||||
)
|
||||
db_session.add(product)
|
||||
db_session.commit()
|
||||
|
||||
assert product.stock == 0
|
||||
assert product.is_active is True
|
||||
assert product.description is None
|
||||
assert product.image_url is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_product_repr(self, db_session):
|
||||
"""Test product string representation"""
|
||||
product = Product(name='Test Product', price=Decimal('9.99'))
|
||||
db_session.add(product)
|
||||
db_session.commit()
|
||||
|
||||
assert repr(product) == '<Product Test Product>'
|
||||
|
||||
|
||||
class TestOrderModel:
|
||||
"""Test Order model"""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_order_creation(self, db_session, regular_user):
|
||||
"""Test creating an order"""
|
||||
order = Order(
|
||||
user_id=regular_user.id,
|
||||
total_amount=Decimal('199.99'),
|
||||
shipping_address='123 Test St'
|
||||
)
|
||||
db_session.add(order)
|
||||
db_session.commit()
|
||||
|
||||
assert order.id is not None
|
||||
assert order.user_id == regular_user.id
|
||||
assert order.total_amount == Decimal('199.99')
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_order_to_dict(self, db_session, regular_user):
|
||||
"""Test order serialization to dictionary"""
|
||||
order = Order(
|
||||
user_id=regular_user.id,
|
||||
total_amount=Decimal('199.99'),
|
||||
shipping_address='123 Test St'
|
||||
)
|
||||
db_session.add(order)
|
||||
db_session.commit()
|
||||
|
||||
order_dict = order.to_dict()
|
||||
assert order_dict['user_id'] == regular_user.id
|
||||
assert order_dict['total_amount'] == 199.99
|
||||
assert isinstance(order_dict['created_at'], str)
|
||||
|
||||
|
||||
class TestOrderItemModel:
|
||||
"""Test OrderItem model"""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_order_item_creation(self, db_session, order, product):
|
||||
"""Test creating an order item"""
|
||||
order_item = OrderItem(
|
||||
order_id=order.id,
|
||||
product_id=product.id,
|
||||
quantity=2,
|
||||
price=product.price
|
||||
)
|
||||
db_session.add(order_item)
|
||||
db_session.commit()
|
||||
|
||||
assert order_item.id is not None
|
||||
assert order_item.order_id == order.id
|
||||
assert order_item.product_id == product.id
|
||||
assert order_item.quantity == 2
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_order_item_to_dict(self, db_session, order, product):
|
||||
"""Test order item serialization to dictionary"""
|
||||
order_item = OrderItem(
|
||||
order_id=order.id,
|
||||
product_id=product.id,
|
||||
quantity=2,
|
||||
price=product.price
|
||||
)
|
||||
db_session.add(order_item)
|
||||
db_session.commit()
|
||||
|
||||
item_dict = order_item.to_dict()
|
||||
assert item_dict['order_id'] == order.id
|
||||
assert item_dict['product_id'] == product.id
|
||||
assert item_dict['quantity'] == 2
|
||||
319
backend/tests/test_routes.py
Normal file
319
backend/tests/test_routes.py
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
"""Test API routes"""
|
||||
import pytest
|
||||
import json
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
class TestAuthRoutes:
|
||||
"""Test authentication routes"""
|
||||
|
||||
@pytest.mark.auth
|
||||
def test_register_success(self, client):
|
||||
"""Test successful user registration"""
|
||||
response = client.post('/api/auth/register', json={
|
||||
'email': 'newuser@example.com',
|
||||
'password': 'password123',
|
||||
'username': 'newuser',
|
||||
'first_name': 'New',
|
||||
'last_name': 'User'
|
||||
})
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.get_json()
|
||||
assert data['email'] == 'newuser@example.com'
|
||||
assert data['username'] == 'newuser'
|
||||
assert 'password' not in data
|
||||
assert 'password_hash' not in data
|
||||
|
||||
@pytest.mark.auth
|
||||
def test_register_missing_fields(self, client):
|
||||
"""Test registration with missing required fields"""
|
||||
response = client.post('/api/auth/register', json={
|
||||
'email': 'newuser@example.com'
|
||||
})
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.get_json()
|
||||
assert 'error' in data
|
||||
|
||||
@pytest.mark.auth
|
||||
def test_register_duplicate_email(self, client, regular_user):
|
||||
"""Test registration with duplicate email"""
|
||||
response = client.post('/api/auth/register', json={
|
||||
'email': regular_user.email,
|
||||
'password': 'password123'
|
||||
})
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.get_json()
|
||||
assert 'already exists' in data['error'].lower()
|
||||
|
||||
@pytest.mark.auth
|
||||
def test_login_success(self, client, regular_user):
|
||||
"""Test successful login"""
|
||||
response = client.post('/api/auth/login', json={
|
||||
'email': regular_user.email,
|
||||
'password': 'password123'
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert 'access_token' in data
|
||||
assert 'refresh_token' in data
|
||||
assert data['user']['email'] == regular_user.email
|
||||
|
||||
@pytest.mark.auth
|
||||
@pytest.mark.parametrize("email,password,expected_status", [
|
||||
("wrong@example.com", "password123", 401),
|
||||
("user@example.com", "wrongpassword", 401),
|
||||
(None, "password123", 400),
|
||||
("user@example.com", None, 400),
|
||||
])
|
||||
def test_login_validation(self, client, regular_user, email, password, expected_status):
|
||||
"""Test login with various invalid inputs"""
|
||||
login_data = {}
|
||||
if email is not None:
|
||||
login_data['email'] = email
|
||||
if password is not None:
|
||||
login_data['password'] = password
|
||||
|
||||
response = client.post('/api/auth/login', json=login_data)
|
||||
assert response.status_code == expected_status
|
||||
|
||||
@pytest.mark.auth
|
||||
def test_login_inactive_user(self, client, inactive_user):
|
||||
"""Test login with inactive user"""
|
||||
response = client.post('/api/auth/login', json={
|
||||
'email': inactive_user.email,
|
||||
'password': 'password123'
|
||||
})
|
||||
|
||||
assert response.status_code == 401
|
||||
data = response.get_json()
|
||||
assert 'inactive' in data['error'].lower()
|
||||
|
||||
@pytest.mark.auth
|
||||
def test_get_current_user(self, client, auth_headers, regular_user):
|
||||
"""Test getting current user"""
|
||||
response = client.get('/api/users/me', headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['email'] == regular_user.email
|
||||
|
||||
@pytest.mark.auth
|
||||
def test_get_current_user_unauthorized(self, client):
|
||||
"""Test getting current user without authentication"""
|
||||
response = client.get('/api/users/me')
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestProductRoutes:
|
||||
"""Test product routes"""
|
||||
|
||||
@pytest.mark.product
|
||||
def test_get_products(self, client, products):
|
||||
"""Test getting all products"""
|
||||
response = client.get('/api/products')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert len(data) == 5
|
||||
|
||||
@pytest.mark.product
|
||||
def test_get_products_empty(self, client):
|
||||
"""Test getting products when none exist"""
|
||||
response = client.get('/api/products')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert len(data) == 0
|
||||
|
||||
@pytest.mark.product
|
||||
def test_get_single_product(self, client, product):
|
||||
"""Test getting a single product"""
|
||||
response = client.get(f'/api/products/{product.id}')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['id'] == product.id
|
||||
assert data['name'] == product.name
|
||||
|
||||
@pytest.mark.product
|
||||
def test_get_product_not_found(self, client):
|
||||
"""Test getting non-existent product"""
|
||||
response = client.get('/api/products/999')
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.product
|
||||
def test_create_product_admin(self, client, admin_headers):
|
||||
"""Test creating product as admin"""
|
||||
response = client.post('/api/products', headers=admin_headers, json={
|
||||
'name': 'New Product',
|
||||
'description': 'A new product',
|
||||
'price': 29.99,
|
||||
'stock': 10
|
||||
})
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.get_json()
|
||||
assert data['name'] == 'New Product'
|
||||
assert data['price'] == 29.99
|
||||
|
||||
@pytest.mark.product
|
||||
def test_create_product_regular_user(self, client, auth_headers):
|
||||
"""Test creating product as regular user (should fail)"""
|
||||
response = client.post('/api/products', headers=auth_headers, json={
|
||||
'name': 'New Product',
|
||||
'price': 29.99
|
||||
})
|
||||
|
||||
assert response.status_code == 403
|
||||
data = response.get_json()
|
||||
assert 'admin' in data['error'].lower()
|
||||
|
||||
@pytest.mark.product
|
||||
def test_create_product_unauthorized(self, client):
|
||||
"""Test creating product without authentication"""
|
||||
response = client.post('/api/products', json={
|
||||
'name': 'New Product',
|
||||
'price': 29.99
|
||||
})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.product
|
||||
def test_create_product_validation_error(self, client, admin_headers):
|
||||
"""Test creating product with invalid data"""
|
||||
response = client.post('/api/products', headers=admin_headers, json={
|
||||
'name': 'New Product',
|
||||
'price': -10.99
|
||||
})
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.get_json()
|
||||
assert 'Validation error' in data['error']
|
||||
|
||||
@pytest.mark.product
|
||||
def test_create_product_missing_required_fields(self, client, admin_headers):
|
||||
"""Test creating product with missing required fields"""
|
||||
response = client.post('/api/products', headers=admin_headers, json={
|
||||
'description': 'Missing name and price'
|
||||
})
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.get_json()
|
||||
assert 'Validation error' in data['error']
|
||||
|
||||
@pytest.mark.product
|
||||
def test_create_product_minimal_data(self, client, admin_headers):
|
||||
"""Test creating product with minimal valid data"""
|
||||
response = client.post('/api/products', headers=admin_headers, json={
|
||||
'name': 'Minimal Product',
|
||||
'price': 19.99
|
||||
})
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.get_json()
|
||||
assert data['name'] == 'Minimal Product'
|
||||
assert data['stock'] == 0 # Default value
|
||||
|
||||
@pytest.mark.product
|
||||
def test_update_product_admin(self, client, admin_headers, product):
|
||||
"""Test updating product as admin"""
|
||||
response = client.put(f'/api/products/{product.id}', headers=admin_headers, json={
|
||||
'name': 'Updated Product',
|
||||
'price': 39.99
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['name'] == 'Updated Product'
|
||||
assert data['price'] == 39.99
|
||||
|
||||
@pytest.mark.product
|
||||
def test_delete_product_admin(self, client, admin_headers, product):
|
||||
"""Test deleting product as admin"""
|
||||
response = client.delete(f'/api/products/{product.id}', headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify product is deleted
|
||||
response = client.get(f'/api/products/{product.id}')
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestOrderRoutes:
|
||||
"""Test order routes"""
|
||||
|
||||
@pytest.mark.order
|
||||
def test_get_orders(self, client, auth_headers, order):
|
||||
"""Test getting orders for current user"""
|
||||
response = client.get('/api/orders', headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert len(data) >= 1
|
||||
|
||||
@pytest.mark.order
|
||||
def test_get_orders_unauthorized(self, client):
|
||||
"""Test getting orders without authentication"""
|
||||
response = client.get('/api/orders')
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.order
|
||||
def test_create_order(self, client, auth_headers, products):
|
||||
"""Test creating an order"""
|
||||
response = client.post('/api/orders', headers=auth_headers, json={
|
||||
'items': [
|
||||
{'product_id': products[0].id, 'quantity': 2},
|
||||
{'product_id': products[1].id, 'quantity': 1}
|
||||
],
|
||||
'shipping_address': '123 Test St'
|
||||
})
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.get_json()
|
||||
assert 'id' in data
|
||||
assert len(data['items']) == 2
|
||||
|
||||
@pytest.mark.order
|
||||
def test_create_order_insufficient_stock(self, client, auth_headers, db_session, products):
|
||||
"""Test creating order with insufficient stock"""
|
||||
# Set stock to 0
|
||||
products[0].stock = 0
|
||||
db_session.commit()
|
||||
|
||||
response = client.post('/api/orders', headers=auth_headers, json={
|
||||
'items': [
|
||||
{'product_id': products[0].id, 'quantity': 2}
|
||||
]
|
||||
})
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.get_json()
|
||||
assert 'insufficient' in data['error'].lower()
|
||||
|
||||
@pytest.mark.order
|
||||
def test_get_single_order(self, client, auth_headers, order):
|
||||
"""Test getting a single order"""
|
||||
response = client.get(f'/api/orders/{order.id}', headers=auth_headers)
|
||||
|
||||
print('test_get_single_order', response.get_json())
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['id'] == order.id
|
||||
|
||||
@pytest.mark.order
|
||||
def test_get_other_users_order(self, client, admin_headers, regular_user, products):
|
||||
"""Test admin accessing another user's order"""
|
||||
# Create an order for regular_user
|
||||
client.post('/api/auth/login', json={
|
||||
'email': regular_user.email,
|
||||
'password': 'password123'
|
||||
})
|
||||
|
||||
# Admin should be able to access any order
|
||||
response = client.get(f'/api/orders/1', headers=admin_headers)
|
||||
# This test assumes order exists, adjust as needed
|
||||
pass
|
||||
277
backend/tests/test_schemas.py
Normal file
277
backend/tests/test_schemas.py
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
"""Test Pydantic schemas"""
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from decimal import Decimal
|
||||
from app.schemas import ProductCreateRequest, ProductResponse
|
||||
|
||||
|
||||
class TestProductCreateRequestSchema:
|
||||
"""Test ProductCreateRequest schema"""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_valid_product_request(self):
|
||||
"""Test valid product creation request"""
|
||||
data = {
|
||||
'name': 'Handcrafted Wooden Bowl',
|
||||
'description': 'A beautiful handcrafted bowl',
|
||||
'price': 45.99,
|
||||
'stock': 10,
|
||||
'image_url': 'https://example.com/bowl.jpg'
|
||||
}
|
||||
|
||||
product = ProductCreateRequest(**data)
|
||||
assert product.name == data['name']
|
||||
assert product.description == data['description']
|
||||
assert product.price == Decimal('45.99')
|
||||
assert product.stock == 10
|
||||
assert product.image_url == data['image_url']
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_minimal_valid_request(self):
|
||||
"""Test minimal valid request (only required fields)"""
|
||||
data = {
|
||||
'name': 'Simple Product',
|
||||
'price': 19.99
|
||||
}
|
||||
|
||||
product = ProductCreateRequest(**data)
|
||||
assert product.name == 'Simple Product'
|
||||
assert product.price == Decimal('19.99')
|
||||
assert product.stock == 0
|
||||
assert product.description is None
|
||||
assert product.image_url is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_missing_name(self):
|
||||
"""Test request with missing name"""
|
||||
data = {
|
||||
'price': 19.99
|
||||
}
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ProductCreateRequest(**data)
|
||||
|
||||
errors = exc_info.value.errors()
|
||||
assert any(error['loc'] == ('name',) for error in errors)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_missing_price(self):
|
||||
"""Test request with missing price"""
|
||||
data = {
|
||||
'name': 'Test Product'
|
||||
}
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ProductCreateRequest(**data)
|
||||
|
||||
errors = exc_info.value.errors()
|
||||
assert any(error['loc'] == ('price',) for error in errors)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_invalid_price_negative(self):
|
||||
"""Test request with negative price"""
|
||||
data = {
|
||||
'name': 'Test Product',
|
||||
'price': -10.99
|
||||
}
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ProductCreateRequest(**data)
|
||||
|
||||
errors = exc_info.value.errors()
|
||||
assert any(error['type'] == 'greater_than' for error in errors)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_invalid_price_zero(self):
|
||||
"""Test request with zero price"""
|
||||
data = {
|
||||
'name': 'Test Product',
|
||||
'price': 0.0
|
||||
}
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ProductCreateRequest(**data)
|
||||
|
||||
errors = exc_info.value.errors()
|
||||
assert any(error['type'] == 'greater_than' for error in errors)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_invalid_price_too_many_decimals(self):
|
||||
"""Test request with too many decimal places"""
|
||||
data = {
|
||||
'name': 'Test Product',
|
||||
'price': 10.999
|
||||
}
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ProductCreateRequest(**data)
|
||||
|
||||
errors = exc_info.value.errors()
|
||||
assert any('decimal places' in str(error).lower() for error in errors)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_invalid_stock_negative(self):
|
||||
"""Test request with negative stock"""
|
||||
data = {
|
||||
'name': 'Test Product',
|
||||
'price': 19.99,
|
||||
'stock': -5
|
||||
}
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ProductCreateRequest(**data)
|
||||
|
||||
errors = exc_info.value.errors()
|
||||
assert any(error['type'] == 'greater_than_equal' for error in errors)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_name_too_long(self):
|
||||
"""Test request with name exceeding max length"""
|
||||
data = {
|
||||
'name': 'A' * 201, # Exceeds 200 character limit
|
||||
'price': 19.99
|
||||
}
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ProductCreateRequest(**data)
|
||||
|
||||
errors = exc_info.value.errors()
|
||||
assert any(error['loc'] == ('name',) for error in errors)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_image_url_too_long(self):
|
||||
"""Test request with image_url exceeding max length"""
|
||||
data = {
|
||||
'name': 'Test Product',
|
||||
'price': 19.99,
|
||||
'image_url': 'A' * 501 # Exceeds 500 character limit
|
||||
}
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ProductCreateRequest(**data)
|
||||
|
||||
errors = exc_info.value.errors()
|
||||
assert any(error['loc'] == ('image_url',) for error in errors)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_price_string_conversion(self):
|
||||
"""Test price string to Decimal conversion"""
|
||||
data = {
|
||||
'name': 'Test Product',
|
||||
'price': '29.99'
|
||||
}
|
||||
|
||||
product = ProductCreateRequest(**data)
|
||||
assert product.price == Decimal('29.99')
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_stock_string_conversion(self):
|
||||
"""Test stock string to int conversion"""
|
||||
data = {
|
||||
'name': 'Test Product',
|
||||
'price': 19.99,
|
||||
'stock': '10'
|
||||
}
|
||||
|
||||
product = ProductCreateRequest(**data)
|
||||
assert product.stock == 10
|
||||
assert isinstance(product.stock, int)
|
||||
|
||||
|
||||
class TestProductResponseSchema:
|
||||
"""Test ProductResponse schema"""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_valid_product_response(self):
|
||||
"""Test valid product response"""
|
||||
data = {
|
||||
'id': 1,
|
||||
'name': 'Test Product',
|
||||
'description': 'A test product',
|
||||
'price': 45.99,
|
||||
'stock': 10,
|
||||
'image_url': 'https://example.com/product.jpg',
|
||||
'is_active': True,
|
||||
'created_at': '2024-01-15T10:30:00',
|
||||
'updated_at': '2024-01-15T10:30:00'
|
||||
}
|
||||
|
||||
product = ProductResponse(**data)
|
||||
assert product.id == 1
|
||||
assert product.name == 'Test Product'
|
||||
assert product.price == 45.99
|
||||
assert product.stock == 10
|
||||
assert product.is_active is True
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_product_response_with_none_fields(self):
|
||||
"""Test product response with optional None fields"""
|
||||
data = {
|
||||
'id': 1,
|
||||
'name': 'Test Product',
|
||||
'price': 19.99,
|
||||
'stock': 0,
|
||||
'is_active': True
|
||||
}
|
||||
|
||||
product = ProductResponse(**data)
|
||||
assert product.description is None
|
||||
assert product.image_url is None
|
||||
assert product.created_at is None
|
||||
assert product.updated_at is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_model_validate_from_sqlalchemy(self, db_session):
|
||||
"""Test validating SQLAlchemy model to Pydantic schema"""
|
||||
from app.models import Product
|
||||
|
||||
db_product = Product(
|
||||
name='Test Product',
|
||||
description='A test product',
|
||||
price=Decimal('45.99'),
|
||||
stock=10
|
||||
)
|
||||
db_session.add(db_product)
|
||||
db_session.commit()
|
||||
|
||||
# Validate using model_validate (for SQLAlchemy models)
|
||||
response = ProductResponse.model_validate(db_product)
|
||||
assert response.name == 'Test Product'
|
||||
assert response.price == 45.99
|
||||
assert response.stock == 10
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_model_dump(self):
|
||||
"""Test model_dump method"""
|
||||
data = {
|
||||
'id': 1,
|
||||
'name': 'Test Product',
|
||||
'price': 19.99,
|
||||
'stock': 5,
|
||||
'is_active': True
|
||||
}
|
||||
|
||||
product = ProductResponse(**data)
|
||||
dumped = product.model_dump()
|
||||
|
||||
assert isinstance(dumped, dict)
|
||||
assert dumped['id'] == 1
|
||||
assert dumped['name'] == 'Test Product'
|
||||
assert dumped['price'] == 19.99
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_model_dump_json(self):
|
||||
"""Test model_dump_json method"""
|
||||
data = {
|
||||
'id': 1,
|
||||
'name': 'Test Product',
|
||||
'price': 19.99,
|
||||
'stock': 5,
|
||||
'is_active': True
|
||||
}
|
||||
|
||||
product = ProductResponse(**data)
|
||||
json_str = product.model_dump_json()
|
||||
|
||||
assert isinstance(json_str, str)
|
||||
assert 'Test Product' in json_str
|
||||
491
docs/testing_guide.md
Normal file
491
docs/testing_guide.md
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
# Testing Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides a comprehensive guide to testing the Flask application using pytest. The testing infrastructure includes unit tests, integration tests, CI/CD pipelines, and pre-commit hooks.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Installation](#installation)
|
||||
2. [Running Tests](#running-tests)
|
||||
3. [Test Structure](#test-structure)
|
||||
4. [Writing Tests](#writing-tests)
|
||||
5. [Fixtures](#fixtures)
|
||||
6. [Coverage](#coverage)
|
||||
7. [CI/CD Pipeline](#cicd-pipeline)
|
||||
8. [Pre-commit Hooks](#pre-commit-hooks)
|
||||
9. [Best Practices](#best-practices)
|
||||
|
||||
## Installation
|
||||
|
||||
### Install Test Dependencies
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements/base.txt
|
||||
```
|
||||
|
||||
The base requirements include:
|
||||
- `pytest==7.4.3` - Testing framework
|
||||
- `pytest-flask==1.3.0` - Flask integration
|
||||
- `pytest-cov==4.1.0` - Coverage reporting
|
||||
- `pytest-mock==3.12.0` - Mocking utilities
|
||||
- `factory-boy==3.3.0` - Test data factories
|
||||
- `faker==20.1.0` - Fake data generation
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Run All Tests
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pytest
|
||||
```
|
||||
|
||||
### Run with Verbose Output
|
||||
|
||||
```bash
|
||||
pytest -v
|
||||
```
|
||||
|
||||
### Run with Coverage Report
|
||||
|
||||
```bash
|
||||
pytest --cov=app --cov-report=html --cov-report=term
|
||||
```
|
||||
|
||||
### Run Specific Test Files
|
||||
|
||||
```bash
|
||||
# Run all model tests
|
||||
pytest tests/test_models.py
|
||||
|
||||
# Run all route tests
|
||||
pytest tests/test_routes.py
|
||||
|
||||
# Run all schema tests
|
||||
pytest tests/test_schemas.py
|
||||
```
|
||||
|
||||
### Run by Test Name
|
||||
|
||||
```bash
|
||||
pytest -k "test_user_creation"
|
||||
pytest -k "test_login"
|
||||
```
|
||||
|
||||
### Run by Markers
|
||||
|
||||
```bash
|
||||
# Run only unit tests
|
||||
pytest -m unit
|
||||
|
||||
# Run only integration tests
|
||||
pytest -m integration
|
||||
|
||||
# Run only authentication tests
|
||||
pytest -m auth
|
||||
|
||||
# Run only product tests
|
||||
pytest -m product
|
||||
|
||||
# Run only order tests
|
||||
pytest -m order
|
||||
```
|
||||
|
||||
### Run Tests in Parallel (faster)
|
||||
|
||||
Install pytest-xdist:
|
||||
```bash
|
||||
pip install pytest-xdist
|
||||
pytest -n auto # Use all available CPUs
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
├── tests/
|
||||
│ ├── __init__.py
|
||||
│ ├── conftest.py # Global fixtures and configuration
|
||||
│ ├── test_models.py # Model tests
|
||||
│ ├── test_routes.py # Route/API tests
|
||||
│ └── test_schemas.py # Pydantic schema tests
|
||||
├── pytest.ini # Pytest configuration
|
||||
├── .coveragerc # Coverage configuration
|
||||
└── app/
|
||||
├── __init__.py
|
||||
├── models/ # Database models
|
||||
├── routes/ # API routes
|
||||
├── schemas/ # Pydantic schemas
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Writing Tests
|
||||
|
||||
### Test File Structure
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from app.models import User
|
||||
from app import db
|
||||
|
||||
class TestUserModel:
|
||||
"""Test User model"""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_user_creation(self, db_session):
|
||||
"""Test creating a user"""
|
||||
user = User(
|
||||
email='test@example.com',
|
||||
username='testuser'
|
||||
)
|
||||
user.set_password('password123')
|
||||
db_session.add(user)
|
||||
db_session.commit()
|
||||
|
||||
assert user.id is not None
|
||||
assert user.email == 'test@example.com'
|
||||
```
|
||||
|
||||
### Test API Routes
|
||||
|
||||
```python
|
||||
def test_get_products(client, products):
|
||||
"""Test getting all products"""
|
||||
response = client.get('/api/products')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert len(data) == 5
|
||||
|
||||
def test_create_product(client, admin_headers):
|
||||
"""Test creating a product"""
|
||||
response = client.post('/api/products',
|
||||
headers=admin_headers,
|
||||
json={
|
||||
'name': 'New Product',
|
||||
'price': 29.99
|
||||
})
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.get_json()
|
||||
assert data['name'] == 'New Product'
|
||||
```
|
||||
|
||||
### Parameterized Tests
|
||||
|
||||
```python
|
||||
@pytest.mark.parametrize("email,password,expected_status", [
|
||||
("user@example.com", "correct123", 200),
|
||||
("wrong@email.com", "correct123", 401),
|
||||
("user@example.com", "wrongpass", 401),
|
||||
])
|
||||
def test_login_validation(client, email, password, expected_status):
|
||||
"""Test login with various inputs"""
|
||||
response = client.post('/api/auth/login', json={
|
||||
'email': email,
|
||||
'password': password
|
||||
})
|
||||
assert response.status_code == expected_status
|
||||
```
|
||||
|
||||
## Fixtures
|
||||
|
||||
### Available Fixtures
|
||||
|
||||
#### Application Fixtures
|
||||
|
||||
- **`app`**: Flask application instance with test configuration
|
||||
- **`client`**: Test client for making HTTP requests
|
||||
- **`runner`**: CLI runner for testing Flask CLI commands
|
||||
- **`db_session`**: Database session for database operations
|
||||
|
||||
#### User Fixtures
|
||||
|
||||
- **`admin_user`**: Creates an admin user
|
||||
- **`regular_user`**: Creates a regular user
|
||||
- **`inactive_user`**: Creates an inactive user
|
||||
|
||||
#### Product Fixtures
|
||||
|
||||
- **`product`**: Creates a single product
|
||||
- **`products`**: Creates 5 products
|
||||
|
||||
#### Authentication Fixtures
|
||||
|
||||
- **`auth_headers`**: JWT headers for regular user
|
||||
- **`admin_headers`**: JWT headers for admin user
|
||||
|
||||
#### Order Fixtures
|
||||
|
||||
- **`order`**: Creates an order with items
|
||||
|
||||
### Creating Custom Fixtures
|
||||
|
||||
```python
|
||||
# In conftest.py or test file
|
||||
@pytest.fixture
|
||||
def custom_product(db_session):
|
||||
"""Create a custom product"""
|
||||
product = Product(
|
||||
name='Custom Product',
|
||||
price=99.99,
|
||||
stock=50
|
||||
)
|
||||
db_session.add(product)
|
||||
db_session.commit()
|
||||
return product
|
||||
|
||||
# Use in tests
|
||||
def test_custom_fixture(custom_product):
|
||||
assert custom_product.name == 'Custom Product'
|
||||
```
|
||||
|
||||
## Coverage
|
||||
|
||||
### Coverage Configuration
|
||||
|
||||
Coverage is configured in `.coveragerc`:
|
||||
|
||||
```ini
|
||||
[run]
|
||||
source = app
|
||||
omit =
|
||||
*/tests/*
|
||||
*/migrations/*
|
||||
*/__pycache__/*
|
||||
|
||||
[report]
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
def __repr__
|
||||
raise NotImplementedError
|
||||
```
|
||||
|
||||
### Coverage Thresholds
|
||||
|
||||
The CI/CD pipeline enforces 80% minimum code coverage.
|
||||
|
||||
### Generate Coverage Report
|
||||
|
||||
```bash
|
||||
# Terminal report
|
||||
pytest --cov=app --cov-report=term
|
||||
|
||||
# HTML report
|
||||
pytest --cov=app --cov-report=html
|
||||
open htmlcov/index.html # Mac
|
||||
xdg-open htmlcov/index.html # Linux
|
||||
```
|
||||
|
||||
### Coverage Report Example
|
||||
|
||||
```
|
||||
Name Stmts Miss Cover Missing
|
||||
----------------------------------------------
|
||||
app/__init__.py 10 2 80% 15-16
|
||||
app/models/user.py 45 5 89% 23, 45
|
||||
app/routes/api.py 120 20 83% 78-85
|
||||
----------------------------------------------
|
||||
TOTAL 175 27 85%
|
||||
```
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
### GitHub Actions Workflow
|
||||
|
||||
The backend has automated testing via GitHub Actions:
|
||||
|
||||
**File**: `.github/workflows/backend-tests.yml`
|
||||
|
||||
### Pipeline Stages
|
||||
|
||||
1. **Test Matrix**: Runs tests on Python 3.10, 3.11, and 3.12
|
||||
2. **Services**: Sets up PostgreSQL and Redis
|
||||
3. **Linting**: Runs flake8 for code quality
|
||||
4. **Testing**: Executes pytest with coverage
|
||||
5. **Coverage Upload**: Sends coverage to Codecov
|
||||
6. **Security Scan**: Runs bandit and safety
|
||||
|
||||
### Triggering the Pipeline
|
||||
|
||||
The pipeline runs automatically on:
|
||||
- Push to `main` or `develop` branches
|
||||
- Pull requests to `main` or `develop` branches
|
||||
- Changes to `backend/**` or workflow files
|
||||
|
||||
### Viewing Results
|
||||
|
||||
1. Go to the Actions tab in your GitHub repository
|
||||
2. Click on the latest workflow run
|
||||
3. View test results, coverage, and artifacts
|
||||
|
||||
## Pre-commit Hooks
|
||||
|
||||
### Setup Pre-commit Hooks
|
||||
|
||||
```bash
|
||||
# Install pre-commit
|
||||
pip install pre-commit
|
||||
|
||||
# Install hooks
|
||||
pre-commit install
|
||||
|
||||
# Run hooks manually
|
||||
pre-commit run --all-files
|
||||
```
|
||||
|
||||
### Available Hooks
|
||||
|
||||
The `.pre-commit-config.yaml` includes:
|
||||
|
||||
1. **Black**: Code formatting
|
||||
2. **isort**: Import sorting
|
||||
3. **flake8**: Linting
|
||||
4. **pytest**: Run tests before committing
|
||||
5. **mypy**: Type checking
|
||||
6. **bandit**: Security checks
|
||||
|
||||
### Hook Behavior
|
||||
|
||||
Hooks run automatically on:
|
||||
- `git commit`
|
||||
- Can be skipped with `git commit --no-verify`
|
||||
|
||||
## Best Practices
|
||||
|
||||
### ✅ DO
|
||||
|
||||
1. **Use descriptive test names**
|
||||
```python
|
||||
def test_user_creation_with_valid_data(): # Good
|
||||
def test_user(): # Bad
|
||||
```
|
||||
|
||||
2. **Test both success and failure cases**
|
||||
```python
|
||||
def test_login_success(): ...
|
||||
def test_login_invalid_credentials(): ...
|
||||
def test_login_missing_fields(): ...
|
||||
```
|
||||
|
||||
3. **Use fixtures for common setup**
|
||||
```python
|
||||
def test_something(client, admin_user, products): ...
|
||||
```
|
||||
|
||||
4. **Mock external services**
|
||||
```python
|
||||
def test_external_api(mocker):
|
||||
mock_response = {'data': 'mocked'}
|
||||
mocker.patch('requests.get', return_value=mock_response)
|
||||
```
|
||||
|
||||
5. **Keep tests independent**
|
||||
- Each test should be able to run alone
|
||||
- Don't rely on test execution order
|
||||
|
||||
6. **Use markers appropriately**
|
||||
```python
|
||||
@pytest.mark.slow
|
||||
def test_expensive_operation(): ...
|
||||
```
|
||||
|
||||
### ❌ DON'T
|
||||
|
||||
1. **Don't share state between tests**
|
||||
```python
|
||||
# Bad - shared state
|
||||
global_user = User(...)
|
||||
|
||||
# Good - use fixtures
|
||||
@pytest.fixture
|
||||
def user(): return User(...)
|
||||
```
|
||||
|
||||
2. **Don't hardcode sensitive data**
|
||||
```python
|
||||
# Bad
|
||||
password = 'real_password_123'
|
||||
|
||||
# Good
|
||||
password = fake.password()
|
||||
```
|
||||
|
||||
3. **Don't use production database**
|
||||
- Always use test database (SQLite)
|
||||
- Fixtures automatically create isolated databases
|
||||
|
||||
4. **Don't skip error cases**
|
||||
```python
|
||||
# Bad - only tests success
|
||||
def test_create_product(): ...
|
||||
|
||||
# Good - tests both
|
||||
def test_create_product_success(): ...
|
||||
def test_create_product_validation_error(): ...
|
||||
```
|
||||
|
||||
5. **Don't ignore slow tests in CI**
|
||||
- Mark slow tests with `@pytest.mark.slow`
|
||||
- Run them separately if needed
|
||||
|
||||
## Test Coverage Requirements
|
||||
|
||||
| Module | Line Coverage | Branch Coverage |
|
||||
|--------|--------------|-----------------|
|
||||
| routes.py | >90% | >85% |
|
||||
| models.py | >85% | >80% |
|
||||
| schemas.py | >90% | >85% |
|
||||
| services/ | >80% | >75% |
|
||||
| utils/ | >70% | >65% |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tests Fail with Database Errors
|
||||
|
||||
```bash
|
||||
# Clean up test databases
|
||||
rm -f backend/*.db
|
||||
```
|
||||
|
||||
### Coverage Not Showing
|
||||
|
||||
```bash
|
||||
# Install coverage separately
|
||||
pip install coverage
|
||||
|
||||
# Clean previous coverage data
|
||||
coverage erase
|
||||
|
||||
# Run tests again
|
||||
pytest --cov=app
|
||||
```
|
||||
|
||||
### Import Errors
|
||||
|
||||
```bash
|
||||
# Ensure you're in the backend directory
|
||||
cd backend
|
||||
|
||||
# Install in development mode
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### Slow Tests
|
||||
|
||||
```bash
|
||||
# Run only specific tests
|
||||
pytest tests/test_routes.py::TestProductRoutes::test_get_products
|
||||
|
||||
# Run in parallel
|
||||
pytest -n auto
|
||||
```
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Pytest Documentation](https://docs.pytest.org/)
|
||||
- [Pytest-Flask Documentation](https://pytest-flask.readthedocs.io/)
|
||||
- [Pydantic Documentation](https://docs.pydantic.dev/)
|
||||
- [Flask Testing Documentation](https://flask.palletsprojects.com/en/latest/testing/)
|
||||
|
|
@ -119,6 +119,18 @@ def create_product():
|
|||
- **ALWAYS** import `db` from `app`
|
||||
- Use `db.session.add()` and `db.session.commit()` for transactions
|
||||
- Use `db.session.flush()` when you need the ID before commit
|
||||
- **ALWAYS** use `db.session.get(Model, id)` instead of `Model.query.get(id)` (SQLAlchemy 2.0)
|
||||
- Use `Model.query.get_or_404(id)` for 404 handling when appropriate
|
||||
```python
|
||||
# ✅ CORRECT - SQLAlchemy 2.0 syntax
|
||||
from app import db
|
||||
from app.models import User
|
||||
|
||||
user = db.session.get(User, user_id)
|
||||
|
||||
# ❌ WRONG - Legacy syntax (deprecated)
|
||||
user = User.query.get(user_id)
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
- Handle common errors (404, 400, 401, 403, 500)
|
||||
|
|
@ -189,11 +201,308 @@ def create_product_service(data):
|
|||
- Use pytest framework
|
||||
- Place tests in `backend/tests/`
|
||||
- Use fixtures for common setup
|
||||
- Organize tests by functionality: `test_models.py`, `test_routes.py`, `test_schemas.py`
|
||||
|
||||
### Test Naming Conventions
|
||||
- Test files must start with `test_`: `test_products.py`, `test_users.py`
|
||||
- Test classes must start with `Test`: `TestProductModel`, `TestAuthRoutes`
|
||||
- Test functions must start with `test_`: `test_create_product`, `test_login_success`
|
||||
- Use descriptive names: `test_create_product_with_valid_data` (not `test_product`)
|
||||
|
||||
### Writing Tests
|
||||
|
||||
#### Basic Test Structure
|
||||
|
||||
```python
|
||||
# ✅ CORRECT
|
||||
import pytest
|
||||
from app import db
|
||||
from app.models import Product
|
||||
|
||||
class TestProductModel:
|
||||
"""Test Product model"""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_product_creation(self, db_session):
|
||||
"""Test creating a product with valid data"""
|
||||
product = Product(
|
||||
name='Test Product',
|
||||
price=99.99
|
||||
)
|
||||
db_session.add(product)
|
||||
db_session.commit()
|
||||
|
||||
assert product.id is not None
|
||||
assert product.name == 'Test Product'
|
||||
```
|
||||
|
||||
#### Testing API Routes
|
||||
|
||||
```python
|
||||
# ✅ CORRECT
|
||||
def test_create_product(client, admin_headers):
|
||||
"""Test creating a product as admin"""
|
||||
response = client.post('/api/products',
|
||||
headers=admin_headers,
|
||||
json={
|
||||
'name': 'New Product',
|
||||
'price': 29.99
|
||||
})
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.get_json()
|
||||
assert data['name'] == 'New Product'
|
||||
assert 'password' not in data
|
||||
```
|
||||
|
||||
#### Using Fixtures
|
||||
|
||||
```python
|
||||
# ✅ CORRECT - Use available fixtures
|
||||
def test_get_products(client, products):
|
||||
"""Test getting all products"""
|
||||
response = client.get('/api/products')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert len(data) == 5
|
||||
|
||||
# ❌ WRONG - Don't create fixtures manually in tests
|
||||
def test_get_products_wrong(client, db_session):
|
||||
products = []
|
||||
for _ in range(5):
|
||||
p = Product(name='Test', price=10)
|
||||
db_session.add(p)
|
||||
products.append(p)
|
||||
db_session.commit()
|
||||
# ... use fixtures instead!
|
||||
```
|
||||
|
||||
#### Testing Both Success and Failure Cases
|
||||
|
||||
```python
|
||||
# ✅ CORRECT - Test both scenarios
|
||||
def test_create_product_success(client, admin_headers):
|
||||
"""Test creating product successfully"""
|
||||
response = client.post('/api/products',
|
||||
headers=admin_headers,
|
||||
json={'name': 'Test', 'price': 10})
|
||||
assert response.status_code == 201
|
||||
|
||||
def test_create_product_unauthorized(client):
|
||||
"""Test creating product without authentication"""
|
||||
response = client.post('/api/products',
|
||||
json={'name': 'Test', 'price': 10})
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_create_product_validation_error(client, admin_headers):
|
||||
"""Test creating product with invalid data"""
|
||||
response = client.post('/api/products',
|
||||
headers=admin_headers,
|
||||
json={'name': 'Test', 'price': -10})
|
||||
assert response.status_code == 400
|
||||
```
|
||||
|
||||
#### Parameterized Tests
|
||||
|
||||
```python
|
||||
# ✅ CORRECT - Use parameterization for similar tests
|
||||
@pytest.mark.parametrize("email,password,expected_status", [
|
||||
("user@example.com", "correct123", 200),
|
||||
("wrong@email.com", "correct123", 401),
|
||||
("user@example.com", "wrongpass", 401),
|
||||
])
|
||||
def test_login_validation(client, email, password, expected_status):
|
||||
"""Test login with various invalid inputs"""
|
||||
response = client.post('/api/auth/login', json={
|
||||
'email': email,
|
||||
'password': password
|
||||
})
|
||||
assert response.status_code == expected_status
|
||||
```
|
||||
|
||||
### Test Markers
|
||||
|
||||
Use appropriate markers for categorizing tests:
|
||||
|
||||
```python
|
||||
# ✅ CORRECT
|
||||
@pytest.mark.unit
|
||||
def test_user_creation(self, db_session):
|
||||
"""Unit test - no HTTP, no external services"""
|
||||
pass
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_user_workflow(self, client):
|
||||
"""Integration test - full request/response cycle"""
|
||||
pass
|
||||
|
||||
@pytest.mark.auth
|
||||
def test_login(self, client):
|
||||
"""Authentication-related test"""
|
||||
pass
|
||||
|
||||
@pytest.mark.product
|
||||
def test_get_products(self, client):
|
||||
"""Product-related test"""
|
||||
pass
|
||||
```
|
||||
|
||||
### Database in Tests
|
||||
- Use in-memory SQLite for tests
|
||||
- Clean up database between tests
|
||||
- Use `pytest.fixture` for database setup
|
||||
- **NEVER** use production database in tests
|
||||
- **NEVER** share state between tests
|
||||
|
||||
```python
|
||||
# ✅ CORRECT - Use db_session fixture
|
||||
def test_something(db_session):
|
||||
user = User(email='test@example.com')
|
||||
db_session.add(user)
|
||||
db_session.commit()
|
||||
|
||||
# ❌ WRONG - Don't access db directly
|
||||
def test_something_wrong():
|
||||
from app import db
|
||||
user = User(email='test@example.com')
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
```
|
||||
|
||||
### Available Fixtures
|
||||
|
||||
Use these fixtures from `tests/conftest.py`:
|
||||
|
||||
- **`app`**: Flask application instance with test configuration
|
||||
- **`client`**: Test client for making HTTP requests
|
||||
- **`runner`**: CLI runner for Flask commands
|
||||
- **`db_session`**: Database session for database operations
|
||||
- **`admin_user`**: Pre-created admin user
|
||||
- **`regular_user`**: Pre-created regular user
|
||||
- **`inactive_user`**: Pre-created inactive user
|
||||
- **`product`**: Single product
|
||||
- **`products`**: Multiple products (5 items)
|
||||
- **`auth_headers`**: JWT headers for regular user
|
||||
- **`admin_headers`**: JWT headers for admin user
|
||||
- **`order`**: Pre-created order with items
|
||||
|
||||
### Creating Custom Fixtures
|
||||
|
||||
```python
|
||||
# In tests/conftest.py or test file
|
||||
@pytest.fixture
|
||||
def custom_resource(db_session):
|
||||
"""Create a custom test resource"""
|
||||
resource = CustomModel(
|
||||
name='Test Resource',
|
||||
value=100
|
||||
)
|
||||
db_session.add(resource)
|
||||
db_session.commit()
|
||||
return resource
|
||||
|
||||
# Use in tests
|
||||
def test_custom_fixture(custom_resource):
|
||||
assert custom_resource.name == 'Test Resource'
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
make test-backend
|
||||
|
||||
# Run with coverage
|
||||
make test-backend-cov
|
||||
|
||||
# Run with verbose output
|
||||
make test-backend-verbose
|
||||
|
||||
# Run specific test file
|
||||
make test-backend-specific TEST=test_models.py
|
||||
|
||||
# Run by marker
|
||||
make test-backend-marker MARKER=auth
|
||||
|
||||
# Run only failed tests
|
||||
make test-backend-failed
|
||||
|
||||
# Run in parallel (faster)
|
||||
make test-backend-parallel
|
||||
```
|
||||
|
||||
### Test Coverage Requirements
|
||||
|
||||
- **Minimum 80%** code coverage required
|
||||
- **Critical paths** (auth, payments, data modification) must have >90% coverage
|
||||
- All new features must include tests
|
||||
|
||||
```python
|
||||
# ✅ CORRECT - Comprehensive test coverage
|
||||
def test_product_crud(self, client, admin_headers):
|
||||
"""Test complete CRUD operations"""
|
||||
# Create
|
||||
response = client.post('/api/products',
|
||||
headers=admin_headers,
|
||||
json={'name': 'Test', 'price': 10})
|
||||
assert response.status_code == 201
|
||||
product_id = response.get_json()['id']
|
||||
|
||||
# Read
|
||||
response = client.get(f'/api/products/{product_id}')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Update
|
||||
response = client.put(f'/api/products/{product_id}',
|
||||
headers=admin_headers,
|
||||
json={'name': 'Updated', 'price': 20})
|
||||
assert response.status_code == 200
|
||||
|
||||
# Delete
|
||||
response = client.delete(f'/api/products/{product_id}',
|
||||
headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
```
|
||||
|
||||
### Mocking External Services
|
||||
|
||||
```python
|
||||
# ✅ CORRECT - Mock external dependencies
|
||||
def test_external_api_call(client, mocker):
|
||||
"""Test endpoint that calls external API"""
|
||||
mock_response = {'data': 'mocked data'}
|
||||
|
||||
# Mock requests.get
|
||||
mock_get = mocker.patch('requests.get')
|
||||
mock_get.return_value.json.return_value = mock_response
|
||||
mock_get.return_value.status_code = 200
|
||||
|
||||
response = client.get('/api/external-data')
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == mock_response
|
||||
mock_get.assert_called_once()
|
||||
```
|
||||
|
||||
### Test DOs and DON'Ts
|
||||
|
||||
✅ **DO:**
|
||||
- Use descriptive test names
|
||||
- Test both success and failure cases
|
||||
- Use fixtures for common setup
|
||||
- Mock external services
|
||||
- Keep tests independent
|
||||
- Use markers appropriately
|
||||
- Test edge cases and boundary conditions
|
||||
|
||||
❌ **DON'T:**
|
||||
- Share state between tests
|
||||
- Hardcode sensitive data (use faker)
|
||||
- Use production database
|
||||
- Skip error case testing
|
||||
- Write tests after deployment
|
||||
- Ignore slow tests in CI
|
||||
- Use complex setup in test methods (use fixtures instead)
|
||||
|
||||
## Security Rules
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue