Compare commits
7 Commits
main
...
a31f700b77
| Author | SHA1 | Date | |
|---|---|---|---|
| a31f700b77 | |||
| 403a0e832f | |||
| 7606001ac2 | |||
| c85e981dc5 | |||
| bc1b914476 | |||
| fc38d8d5fb | |||
| 9772c3c908 |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -2,7 +2,7 @@
|
|||||||
site/
|
site/
|
||||||
|
|
||||||
# Python cache (ignore all)
|
# Python cache (ignore all)
|
||||||
**/__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
*.pyc
|
*.pyc
|
||||||
@@ -11,10 +11,13 @@ site/
|
|||||||
# opencode skills (agent-only)
|
# opencode skills (agent-only)
|
||||||
.opencode/
|
.opencode/
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
.github/
|
|
||||||
|
|
||||||
# Scripts (except hooks)
|
# Scripts (except hooks)
|
||||||
scripts/
|
scripts/*
|
||||||
|
!scripts/commit-msg
|
||||||
|
!scripts/post-commit
|
||||||
|
!scripts/update_readme.py
|
||||||
|
!scripts/clean_cache.sh
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@@ -31,4 +31,22 @@ repos:
|
|||||||
language: system
|
language: system
|
||||||
types: [ python ]
|
types: [ python ]
|
||||||
|
|
||||||
|
# Update README.md (before commit)
|
||||||
|
- id: update-readme
|
||||||
|
name: update-readme
|
||||||
|
language: system
|
||||||
|
entry: uv run python scripts/update_readme.py
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
|
stages: [ pre-commit ]
|
||||||
|
|
||||||
|
# Clean Python cache
|
||||||
|
- id: clean-pycache
|
||||||
|
name: clean-pycache
|
||||||
|
language: system
|
||||||
|
entry: bash scripts/clean_cache.sh
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
|
stages: [ pre-commit ]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
when:
|
|
||||||
event: [push, pull_request]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: comment
|
|
||||||
image: mcs94/gitea-comment
|
|
||||||
settings:
|
|
||||||
gitea_address: https://git.pyaqa.ru
|
|
||||||
gitea_token:
|
|
||||||
from_secret: gitea_token
|
|
||||||
comment: >
|
|
||||||
✅ Build ${CI_BUILD_EVENT} of `${CI_REPO_NAME}` has status `${CI_BUILD_STATUS}`.
|
|
||||||
|
|
||||||
📝 Commit by ${CI_COMMIT_AUTHOR} on `${CI_COMMIT_BRANCH}`:
|
|
||||||
|
|
||||||
`${CI_COMMIT_MESSAGE}`
|
|
||||||
|
|
||||||
🌐 ${CI_BUILD_LINK}
|
|
||||||
|
|
||||||
depends_on:
|
|
||||||
- lint
|
|
||||||
- type
|
|
||||||
- test
|
|
||||||
|
|
||||||
18
.woodpecker/deploy.yaml
Normal file
18
.woodpecker/deploy.yaml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
when:
|
||||||
|
event: [push]
|
||||||
|
branch: main
|
||||||
|
|
||||||
|
steps:
|
||||||
|
deploy:
|
||||||
|
image: python:3.13
|
||||||
|
commands:
|
||||||
|
- echo "🚀 Deploying to production..."
|
||||||
|
- echo "Branch: main"
|
||||||
|
- echo "Commit: $(git rev-parse --short HEAD)"
|
||||||
|
# Add your deployment commands here
|
||||||
|
# Example:
|
||||||
|
# - uv sync --frozen
|
||||||
|
# - uv run python -m app.main &
|
||||||
|
- echo "✅ Deployment complete"
|
||||||
|
when:
|
||||||
|
status: [success]
|
||||||
49
.woodpecker/git-check.yaml
Normal file
49
.woodpecker/git-check.yaml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
when:
|
||||||
|
event: [push, pull_request]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
check-branch:
|
||||||
|
image: alpine/git
|
||||||
|
commands:
|
||||||
|
- BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||||
|
- |
|
||||||
|
echo "Branch: $BRANCH"
|
||||||
|
if [ "$BRANCH" = "main" ]; then
|
||||||
|
echo "✓ Production branch (protected)"
|
||||||
|
elif [ "$BRANCH" = "dev" ]; then
|
||||||
|
echo "✓ Development branch (protected)"
|
||||||
|
elif echo "$BRANCH" | grep -qE "^feature/"; then
|
||||||
|
echo "✓ Feature branch"
|
||||||
|
elif echo "$BRANCH" | grep -qE "^(fix|hotfix|release)/"; then
|
||||||
|
echo "✓ Special branch"
|
||||||
|
else
|
||||||
|
echo "⚠️ Unusual branch name: $BRANCH"
|
||||||
|
echo " Recommended: feature/<description>, fix/<description>"
|
||||||
|
fi
|
||||||
|
|
||||||
|
check-commit-message:
|
||||||
|
image: alpine/git
|
||||||
|
commands:
|
||||||
|
- MSG=$(git log -1 --pretty=%s)
|
||||||
|
- |
|
||||||
|
echo "Last commit: $MSG"
|
||||||
|
if echo "$MSG" | grep -qE "^(feat|fix|docs|style|refactor|test|chore): [a-z]"; then
|
||||||
|
echo "✓ Commit message follows convention"
|
||||||
|
else
|
||||||
|
echo "❌ Invalid commit message format"
|
||||||
|
echo " Expected: <type>: <description>"
|
||||||
|
echo " Types: feat, fix, docs, style, refactor, test, chore"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
check-cache-files:
|
||||||
|
image: python:3.13
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
CACHE_FILES=$(git diff --name-only HEAD~1 | grep -E "__pycache__|\.pyc$" || true)
|
||||||
|
if [ -n "$CACHE_FILES" ]; then
|
||||||
|
echo "❌ Cache files in commit:"
|
||||||
|
echo "$CACHE_FILES"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
- echo "✓ No cache files in commit"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
when:
|
when:
|
||||||
- event: [push, pull_request]
|
- event: [push, pull_request]
|
||||||
branch: dev
|
branch: main
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: lint
|
- name: lint
|
||||||
@@ -11,4 +11,3 @@ steps:
|
|||||||
- uv run black --check .
|
- uv run black --check .
|
||||||
- uv run ruff check .
|
- uv run ruff check .
|
||||||
- uv run isort --check-only .
|
- uv run isort --check-only .
|
||||||
|
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
when:
|
when:
|
||||||
- event: [push, pull_request]
|
- event: [push, pull_request]
|
||||||
branch: dev
|
branch: main
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: test
|
- name: tests
|
||||||
image: python:3.11
|
image: python:3.11
|
||||||
commands:
|
commands:
|
||||||
- pip install uv
|
- pip install uv
|
||||||
- uv sync --no-dev --group tests
|
- uv sync --no-dev --group tests
|
||||||
- uv run pytest --cov=app --cov-fail-under=70 --cov-report=term-missing
|
- uv run pytest --cov=app --cov-fail-under=70 --cov-report=term-missing
|
||||||
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
when:
|
when:
|
||||||
- event: [push, pull_request]
|
- event: [push, pull_request]
|
||||||
branch: dev
|
branch: main
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: type
|
- name: types
|
||||||
image: python:3.11
|
image: python:3.11
|
||||||
commands:
|
commands:
|
||||||
- pip install uv
|
- pip install uv
|
||||||
145
README.md
145
README.md
@@ -1,5 +1,146 @@
|
|||||||
# blog.pyaqa.ru
|
# blog.pyaqa.ru
|
||||||
|
|
||||||
Блог pyaqa
|
|
||||||
|
|
||||||
[](https://cicd.pyaqa.ru/repos/2)
|
[](https://cicd.pyaqa.ru/repos/2)
|
||||||
|
|
||||||
|
Блог pyaqa - FastAPI приложение для блога.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- FastAPI REST API
|
||||||
|
- Python 3.13+
|
||||||
|
- Async/await support
|
||||||
|
- Type hints throughout
|
||||||
|
- Comprehensive testing
|
||||||
|
- Auto-generated documentation
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.13+
|
||||||
|
- uv package manager
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python -m app.main
|
||||||
|
```
|
||||||
|
|
||||||
|
Server runs on `http://0.0.0.0:8000`
|
||||||
|
|
||||||
|
API documentation:
|
||||||
|
- Swagger UI: http://localhost:8000/docs
|
||||||
|
- ReDoc: http://localhost:8000/redoc
|
||||||
|
|
||||||
|
## Available Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `uv sync` | Install dependencies |
|
||||||
|
| `uv run python -m app.main` | Start development server |
|
||||||
|
| `uv run pytest --cov=app --cov-fail-under=70` | Run tests with coverage |
|
||||||
|
| `uv run ruff check . --fix` | Run linters |
|
||||||
|
| `uv run ruff format .` | Format code |
|
||||||
|
| `uv run isort . --profile black --filter-files` | Sort imports |
|
||||||
|
| `uv run mypy .` | Type checking |
|
||||||
|
| `uv run mkdocs build` | Build documentation |
|
||||||
|
| `uv run mkdocs serve` | Serve documentation locally |
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Runtime
|
||||||
|
- dishka>=1.0.0
|
||||||
|
- fastapi>=0.136.0
|
||||||
|
- pydantic-settings>=2.7.0
|
||||||
|
- uvicorn>=0.44.0
|
||||||
|
|
||||||
|
### Development
|
||||||
|
- **Tests**: httpx>=0.28.1, mimesis>=13.0.0, pytest-asyncio>=1.3.0, pytest-cov>=7.1.0, pytest>=9.0.3
|
||||||
|
- **Lint**: black>=23.7.0, isort>=8.0.1, ruff>=0.15.11
|
||||||
|
- **Types**: mypy>=1.20.1
|
||||||
|
- **Docs**: interrogate>=1.7.0, mkdocs>=1.6.0, mkdocstrings[python]>=0.24.0, pydocstyle>=6.3.0
|
||||||
|
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── main.py
|
||||||
|
├── core/
|
||||||
|
│ ├── config.py
|
||||||
|
│ └── exceptions.py
|
||||||
|
├── api/
|
||||||
|
│ └── v1/
|
||||||
|
├── modules/
|
||||||
|
└── common/
|
||||||
|
└── error_handler.py
|
||||||
|
tests/
|
||||||
|
├── api/
|
||||||
|
├── unit/
|
||||||
|
├── integration/
|
||||||
|
└── e2e/
|
||||||
|
docs/
|
||||||
|
├── api/
|
||||||
|
└── development/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Layered Architecture:
|
||||||
|
- **API Layer** - HTTP endpoints, request/response handling
|
||||||
|
- **Service Layer** - Business logic, orchestration
|
||||||
|
- **Repository Layer** - Data access abstraction
|
||||||
|
- **Database** - Persistence
|
||||||
|
|
||||||
|
Dependency Injection: **Dishka** (not FastAPI Depends)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
uv run pytest --cov=app --cov-fail-under=70
|
||||||
|
|
||||||
|
# Run by type
|
||||||
|
uv run pytest tests/unit/ -v
|
||||||
|
uv run pytest tests/api/ -v
|
||||||
|
uv run pytest -m unit -v
|
||||||
|
uv run pytest -m api -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build docs
|
||||||
|
uv run mkdocs build
|
||||||
|
|
||||||
|
# Serve locally
|
||||||
|
uv run mkdocs serve
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
Woodpecker CI is configured in `.woodpecker/` directory.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
See [LICENSE](LICENSE) file.
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### [v0.1.0] - 2026-04-25
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
- feat: update project structure and docs (9772c3c)
|
||||||
|
- [QG] Add quality gates on main branch (9c3b44b)
|
||||||
|
- [Lint] add pipeline (fa8751c)
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
- pipeline updated (7cd0d8c)
|
||||||
|
|
||||||
|
#### Other
|
||||||
|
- refactor: remove all inline comments from code (c85e981)
|
||||||
|
- Dev dependencies (0480712)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
@@ -11,7 +11,7 @@ from app.core.exceptions import AppException
|
|||||||
class ErrorResponse(BaseModel):
|
class ErrorResponse(BaseModel):
|
||||||
status_code: int
|
status_code: int
|
||||||
message: str
|
message: str
|
||||||
details: dict[str, str] | None = None
|
details: dict | None = None
|
||||||
timestamp: str
|
timestamp: str
|
||||||
|
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ async def app_exception_handler(request: Request, exc: AppException) -> JSONResp
|
|||||||
content={
|
content={
|
||||||
"status_code": exc.status_code,
|
"status_code": exc.status_code,
|
||||||
"message": exc.message,
|
"message": exc.message,
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,12 +32,12 @@ async def http_exception_handler(request: Request, exc: HTTPException) -> JSONRe
|
|||||||
content={
|
content={
|
||||||
"status_code": exc.status_code,
|
"status_code": exc.status_code,
|
||||||
"message": str(exc.detail),
|
"message": str(exc.detail),
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def register_exception_handlers(app: FastAPI) -> None:
|
def register_exception_handlers(app: FastAPI):
|
||||||
app.add_exception_handler(
|
app.add_exception_handler(
|
||||||
AppException,
|
AppException,
|
||||||
app_exception_handler, # type: ignore[arg-type]
|
app_exception_handler, # type: ignore[arg-type]
|
||||||
|
|||||||
15
app/main.py
15
app/main.py
@@ -1,22 +1,27 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import AsyncGenerator
|
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from app.common.error_handler import register_exception_handlers
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
async def lifespan(app: FastAPI):
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
def app_factory() -> FastAPI:
|
def app_factory() -> FastAPI:
|
||||||
app = FastAPI(lifespan=lifespan)
|
app = FastAPI(title=settings.app_name, debug=settings.debug, lifespan=lifespan)
|
||||||
|
|
||||||
|
register_exception_handlers(app)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main():
|
||||||
uvicorn.run(app_factory, factory=True, host="0.0.0.0", port=8000)
|
uvicorn.run(app_factory, factory=True, host=settings.host, port=settings.port)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ readme = "README.md"
|
|||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.136.0",
|
"fastapi>=0.136.0",
|
||||||
"pydantic>=2.13.2",
|
|
||||||
"pydantic-settings>=2.14.0",
|
|
||||||
"uvicorn>=0.44.0",
|
"uvicorn>=0.44.0",
|
||||||
|
"pydantic-settings>=2.7.0",
|
||||||
|
"dishka>=1.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
@@ -16,14 +16,15 @@ dev = [
|
|||||||
{include-group = "lints"},
|
{include-group = "lints"},
|
||||||
{include-group = "tests"},
|
{include-group = "tests"},
|
||||||
{include-group = "types"},
|
{include-group = "types"},
|
||||||
|
{include-group = "docs"},
|
||||||
"pre-commit>=4.5.1",
|
"pre-commit>=4.5.1",
|
||||||
]
|
]
|
||||||
tests = [
|
tests = [
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
"mimesis>=19.1.0",
|
|
||||||
"pytest>=9.0.3",
|
"pytest>=9.0.3",
|
||||||
"pytest-asyncio>=1.3.0",
|
"pytest-asyncio>=1.3.0",
|
||||||
"pytest-cov>=7.1.0",
|
"pytest-cov>=7.1.0",
|
||||||
|
"mimesis>=13.0.0",
|
||||||
]
|
]
|
||||||
lints = [
|
lints = [
|
||||||
"black>=23.7.0",
|
"black>=23.7.0",
|
||||||
@@ -31,23 +32,33 @@ lints = [
|
|||||||
"isort>=8.0.1",
|
"isort>=8.0.1",
|
||||||
]
|
]
|
||||||
types = [
|
types = [
|
||||||
"mimesis>=19.1.0",
|
|
||||||
"mypy>=1.20.1",
|
"mypy>=1.20.1",
|
||||||
]
|
]
|
||||||
|
docs = [
|
||||||
|
"pydocstyle>=6.3.0",
|
||||||
|
"interrogate>=1.7.0",
|
||||||
|
"mkdocs>=1.6.0",
|
||||||
|
"mkdocstrings[python]>=0.24.0",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
asyncio_default_fixture_loop_scope = "function"
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
addopts = "--cov=src --cov-report=term"
|
addopts = "--cov=app --cov-report=term --no-cov-on-fail -p no:cacheprovider"
|
||||||
pythonpath = "."
|
pythonpath = "."
|
||||||
testpaths = "tests"
|
testpaths = "tests"
|
||||||
xfail_strict = true
|
xfail_strict = true
|
||||||
|
markers = [
|
||||||
|
"api: API endpoint tests",
|
||||||
|
"unit: Unit tests (isolated, fast)",
|
||||||
|
"integration: Integration tests (DB, external services)",
|
||||||
|
"e2e: End-to-end tests (full workflows)",
|
||||||
|
"slow: Slow running tests (skip in CI by default)",
|
||||||
|
]
|
||||||
|
# Disable bytecode generation
|
||||||
|
env = ["PYTHONDONTWRITEBYTECODE=1"]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.uv]
|
||||||
strict = true
|
# Disable Python bytecode cache during development
|
||||||
plugins = ["pydantic.mypy"]
|
# Set PYTHONDONTWRITEBYTECODE=1 in .env or environment
|
||||||
|
|
||||||
[tool.isort]
|
|
||||||
profile = "black"
|
|
||||||
filter_files = true
|
|
||||||
|
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ def update_readme(check_only: bool = False) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main():
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
check_only = "--check" in sys.argv
|
check_only = "--check" in sys.argv
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
# API test fixtures
|
# API test fixtures
|
||||||
# Provides: httpx.AsyncClient, authentication helpers, test API data
|
# Provides: httpx.AsyncClient, authentication helpers, test API data
|
||||||
|
|
||||||
from typing import AsyncGenerator
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def client() -> AsyncGenerator[AsyncClient, None]:
|
async def client():
|
||||||
"""Create async HTTP client for API testing."""
|
"""Create async HTTP client for API testing."""
|
||||||
from app.main import app_factory
|
from app.main import app_factory
|
||||||
|
|
||||||
@@ -19,6 +17,6 @@ async def client() -> AsyncGenerator[AsyncClient, None]:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def auth_headers() -> dict[str, str]:
|
def auth_headers():
|
||||||
"""Return mock authentication headers."""
|
"""Return mock authentication headers."""
|
||||||
return {"Authorization": "Bearer test_token"}
|
return {"Authorization": "Bearer test_token"}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from asyncio import AbstractEventLoopPolicy, DefaultEventLoopPolicy
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def event_loop_policy() -> AbstractEventLoopPolicy:
|
def event_loop_policy():
|
||||||
return DefaultEventLoopPolicy()
|
import asyncio
|
||||||
|
|
||||||
|
return asyncio.DefaultEventLoopPolicy()
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
# E2E test fixtures
|
# E2E test fixtures
|
||||||
# Provides: full application state, end-to-end workflows, cleanup
|
# Provides: full application state, end-to-end workflows, cleanup
|
||||||
|
|
||||||
from typing import AsyncGenerator
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def e2e_app() -> AsyncGenerator[FastAPI, None]:
|
async def e2e_app():
|
||||||
"""Create full application instance for E2E testing."""
|
"""Create full application instance for E2E testing."""
|
||||||
from app.main import app_factory
|
from app.main import app_factory
|
||||||
|
|
||||||
@@ -18,7 +15,7 @@ async def e2e_app() -> AsyncGenerator[FastAPI, None]:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def e2e_user_data() -> dict[str, str]:
|
def e2e_user_data():
|
||||||
"""Generate realistic user data for E2E scenarios."""
|
"""Generate realistic user data for E2E scenarios."""
|
||||||
from mimesis import Person
|
from mimesis import Person
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
# Integration test fixtures
|
# Integration test fixtures
|
||||||
# Provides: test database, external service connections
|
# Provides: test database, external service connections
|
||||||
|
|
||||||
from typing import Generator
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_db_connection() -> Generator[str, None, None]:
|
def test_db_connection():
|
||||||
"""Create test database connection."""
|
"""Create test database connection."""
|
||||||
# TODO: Implement when DB is added to project
|
# TODO: Implement when DB is added to project
|
||||||
yield "test_db"
|
yield "test_db"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def cleanup_db() -> Generator[None, None, None]:
|
def cleanup_db():
|
||||||
"""Cleanup database after test."""
|
"""Cleanup database after test."""
|
||||||
yield
|
yield
|
||||||
# TODO: Implement cleanup logic
|
# TODO: Implement cleanup logic
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
from contextlib import asynccontextmanager
|
|
||||||
from unittest.mock import Mock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import FastAPI
|
|
||||||
|
|
||||||
# Предполагаем, что тестируемый модуль называется `myapp`
|
|
||||||
# Импортируем из него нужные объекты
|
|
||||||
from app.main import app_factory, lifespan, main
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_lifespan() -> None:
|
|
||||||
"""Проверяет, что lifespan является корректным асинхронным контекстным менеджером."""
|
|
||||||
app = FastAPI()
|
|
||||||
# Проверяем, что lifespan - это asynccontextmanager
|
|
||||||
assert isinstance(lifespan, asynccontextmanager(lifespan).__class__) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
# Проверяем, что контекстный менеджер работает (ничего не ломается)
|
|
||||||
async with lifespan(app):
|
|
||||||
pass # Просто убеждаемся, что yield отрабатывает
|
|
||||||
|
|
||||||
|
|
||||||
def test_app_factory() -> None:
|
|
||||||
"""Проверяет, что app_factory создаёт приложение FastAPI с переданным lifespan."""
|
|
||||||
app = app_factory()
|
|
||||||
assert isinstance(app, FastAPI)
|
|
||||||
# Проверяем, что lifespan приложения установлен на функцию lifespan
|
|
||||||
assert app.router.lifespan_context == lifespan
|
|
||||||
|
|
||||||
|
|
||||||
@patch("app.main.uvicorn.run")
|
|
||||||
def test_main(mock_uvicorn_run: Mock) -> None:
|
|
||||||
"""Проверяет, что main вызывает uvicorn.run с правильными параметрами."""
|
|
||||||
main()
|
|
||||||
mock_uvicorn_run.assert_called_once_with(
|
|
||||||
app_factory,
|
|
||||||
factory=True,
|
|
||||||
host="0.0.0.0",
|
|
||||||
port=8000, # Предполагаемый порт (в коде обрезано, но обычно 8000)
|
|
||||||
)
|
|
||||||
@@ -7,12 +7,12 @@ import pytest
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_service() -> Mock:
|
def mock_service():
|
||||||
"""Create a mock service for unit testing."""
|
"""Create a mock service for unit testing."""
|
||||||
return Mock()
|
return Mock()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_async_service() -> AsyncMock:
|
def mock_async_service():
|
||||||
"""Create an async mock service for unit testing."""
|
"""Create an async mock service for unit testing."""
|
||||||
return AsyncMock()
|
return AsyncMock()
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import os
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from app.core.config import Settings
|
|
||||||
|
|
||||||
|
|
||||||
class TestSettings:
|
|
||||||
def test_default_values(self) -> None:
|
|
||||||
settings = Settings()
|
|
||||||
assert settings.app_name == "Blog API"
|
|
||||||
assert settings.debug is False
|
|
||||||
assert settings.host == "0.0.0.0"
|
|
||||||
assert settings.port == 8000
|
|
||||||
assert settings.database_url is None
|
|
||||||
|
|
||||||
def test_custom_values(self) -> None:
|
|
||||||
settings = Settings(
|
|
||||||
app_name="Test API",
|
|
||||||
debug=True,
|
|
||||||
host="localhost",
|
|
||||||
port=9000,
|
|
||||||
database_url="postgresql://test",
|
|
||||||
)
|
|
||||||
assert settings.app_name == "Test API"
|
|
||||||
assert settings.debug is True
|
|
||||||
assert settings.host == "localhost"
|
|
||||||
assert settings.port == 9000
|
|
||||||
assert settings.database_url == "postgresql://test"
|
|
||||||
|
|
||||||
def test_settings_from_env(self) -> None:
|
|
||||||
with patch.dict(
|
|
||||||
os.environ,
|
|
||||||
{
|
|
||||||
"APP_NAME": "Env API",
|
|
||||||
"DEBUG": "true",
|
|
||||||
"HOST": "127.0.0.1",
|
|
||||||
"PORT": "8080",
|
|
||||||
"DATABASE_URL": "sqlite:///test.db",
|
|
||||||
},
|
|
||||||
):
|
|
||||||
settings = Settings()
|
|
||||||
assert settings.app_name == "Env API"
|
|
||||||
assert settings.debug is True
|
|
||||||
assert settings.host == "127.0.0.1"
|
|
||||||
assert settings.port == 8080
|
|
||||||
assert settings.database_url == "sqlite:///test.db"
|
|
||||||
|
|
||||||
def test_global_settings_instance(self) -> None:
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
assert isinstance(settings, Settings)
|
|
||||||
assert settings.app_name == "Blog API"
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
from datetime import datetime, timezone
|
|
||||||
from unittest.mock import Mock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import FastAPI, Request
|
|
||||||
from starlette.exceptions import HTTPException
|
|
||||||
|
|
||||||
from app.common.error_handler import (
|
|
||||||
ErrorResponse,
|
|
||||||
app_exception_handler,
|
|
||||||
http_exception_handler,
|
|
||||||
register_exception_handlers,
|
|
||||||
)
|
|
||||||
from app.core.exceptions import AppException
|
|
||||||
|
|
||||||
|
|
||||||
class TestErrorResponse:
|
|
||||||
def test_error_response_creation(self) -> None:
|
|
||||||
response = ErrorResponse(
|
|
||||||
status_code=400,
|
|
||||||
message="Bad request",
|
|
||||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
||||||
)
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert response.message == "Bad request"
|
|
||||||
assert response.details is None
|
|
||||||
|
|
||||||
def test_error_response_with_details(self) -> None:
|
|
||||||
response = ErrorResponse(
|
|
||||||
status_code=500,
|
|
||||||
message="Internal error",
|
|
||||||
details={"field": "value"},
|
|
||||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
||||||
)
|
|
||||||
assert response.status_code == 500
|
|
||||||
assert response.message == "Internal error"
|
|
||||||
assert response.details == {"field": "value"}
|
|
||||||
|
|
||||||
|
|
||||||
class TestAppExceptionHandler:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_app_exception_handler(self) -> None:
|
|
||||||
request = Mock(spec=Request)
|
|
||||||
exc = AppException(message="Test error", status_code=400)
|
|
||||||
|
|
||||||
response = await app_exception_handler(request, exc)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
body = bytes(response.body).decode()
|
|
||||||
assert "Test error" in body
|
|
||||||
assert "400" in body
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_app_exception_handler_content(self) -> None:
|
|
||||||
request = Mock(spec=Request)
|
|
||||||
exc = AppException(message="Validation error", status_code=422)
|
|
||||||
|
|
||||||
with patch("app.common.error_handler.datetime") as mock_datetime:
|
|
||||||
mock_datetime.now.return_value.isoformat.return_value = (
|
|
||||||
"2024-01-01T00:00:00"
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await app_exception_handler(request, exc)
|
|
||||||
|
|
||||||
content = bytes(response.body).decode()
|
|
||||||
assert "Validation error" in content
|
|
||||||
assert "422" in content
|
|
||||||
assert "2024-01-01T00:00:00" in content
|
|
||||||
|
|
||||||
|
|
||||||
class TestHttpExceptionHandler:
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_http_exception_handler(self) -> None:
|
|
||||||
request = Mock(spec=Request)
|
|
||||||
exc = HTTPException(status_code=404, detail="Not found")
|
|
||||||
|
|
||||||
response = await http_exception_handler(request, exc)
|
|
||||||
|
|
||||||
assert response.status_code == 404
|
|
||||||
body = bytes(response.body).decode()
|
|
||||||
assert "Not found" in body
|
|
||||||
assert "404" in body
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_http_exception_handler_content(self) -> None:
|
|
||||||
request = Mock(spec=Request)
|
|
||||||
exc = HTTPException(status_code=503, detail="Service unavailable")
|
|
||||||
|
|
||||||
with patch("app.common.error_handler.datetime") as mock_datetime:
|
|
||||||
mock_datetime.now.return_value.isoformat.return_value = (
|
|
||||||
"2024-01-01T12:00:00"
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await http_exception_handler(request, exc)
|
|
||||||
|
|
||||||
content = bytes(response.body).decode()
|
|
||||||
assert "Service unavailable" in content
|
|
||||||
assert "503" in content
|
|
||||||
assert "2024-01-01T12:00:00" in content
|
|
||||||
|
|
||||||
|
|
||||||
class TestRegisterExceptionHandlers:
|
|
||||||
def test_register_exception_handlers(self) -> None:
|
|
||||||
app = Mock(spec=FastAPI)
|
|
||||||
|
|
||||||
register_exception_handlers(app)
|
|
||||||
|
|
||||||
assert app.add_exception_handler.call_count == 2
|
|
||||||
app.add_exception_handler.assert_any_call(AppException, app_exception_handler)
|
|
||||||
app.add_exception_handler.assert_any_call(HTTPException, http_exception_handler)
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
from app.core.exceptions import (
|
|
||||||
AppException,
|
|
||||||
ForbiddenError,
|
|
||||||
NotFoundError,
|
|
||||||
UnauthorizedError,
|
|
||||||
ValidationError,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestAppException:
|
|
||||||
def test_default_status_code(self) -> None:
|
|
||||||
exc = AppException(message="Test error")
|
|
||||||
assert exc.message == "Test error"
|
|
||||||
assert exc.status_code == 500
|
|
||||||
|
|
||||||
def test_custom_status_code(self) -> None:
|
|
||||||
exc = AppException(message="Custom error", status_code=400)
|
|
||||||
assert exc.message == "Custom error"
|
|
||||||
assert exc.status_code == 400
|
|
||||||
|
|
||||||
def test_string_representation(self) -> None:
|
|
||||||
exc = AppException(message="Error message")
|
|
||||||
assert str(exc) == "Error message"
|
|
||||||
|
|
||||||
|
|
||||||
class TestNotFoundError:
|
|
||||||
def test_default_message(self) -> None:
|
|
||||||
exc = NotFoundError()
|
|
||||||
assert exc.message == "Resource not found"
|
|
||||||
assert exc.status_code == 404
|
|
||||||
|
|
||||||
def test_custom_message(self) -> None:
|
|
||||||
exc = NotFoundError(message="Item not found")
|
|
||||||
assert exc.message == "Item not found"
|
|
||||||
assert exc.status_code == 404
|
|
||||||
|
|
||||||
def test_is_subclass_of_app_exception(self) -> None:
|
|
||||||
exc = NotFoundError()
|
|
||||||
assert isinstance(exc, AppException)
|
|
||||||
|
|
||||||
|
|
||||||
class TestValidationError:
|
|
||||||
def test_default_message(self) -> None:
|
|
||||||
exc = ValidationError()
|
|
||||||
assert exc.message == "Validation failed"
|
|
||||||
assert exc.status_code == 400
|
|
||||||
|
|
||||||
def test_custom_message(self) -> None:
|
|
||||||
exc = ValidationError(message="Invalid email format")
|
|
||||||
assert exc.message == "Invalid email format"
|
|
||||||
assert exc.status_code == 400
|
|
||||||
|
|
||||||
def test_is_subclass_of_app_exception(self) -> None:
|
|
||||||
exc = ValidationError()
|
|
||||||
assert isinstance(exc, AppException)
|
|
||||||
|
|
||||||
|
|
||||||
class TestUnauthorizedError:
|
|
||||||
def test_default_message(self) -> None:
|
|
||||||
exc = UnauthorizedError()
|
|
||||||
assert exc.message == "Unauthorized"
|
|
||||||
assert exc.status_code == 401
|
|
||||||
|
|
||||||
def test_custom_message(self) -> None:
|
|
||||||
exc = UnauthorizedError(message="Invalid credentials")
|
|
||||||
assert exc.message == "Invalid credentials"
|
|
||||||
assert exc.status_code == 401
|
|
||||||
|
|
||||||
def test_is_subclass_of_app_exception(self) -> None:
|
|
||||||
exc = UnauthorizedError()
|
|
||||||
assert isinstance(exc, AppException)
|
|
||||||
|
|
||||||
|
|
||||||
class TestForbiddenError:
|
|
||||||
def test_default_message(self) -> None:
|
|
||||||
exc = ForbiddenError()
|
|
||||||
assert exc.message == "Forbidden"
|
|
||||||
assert exc.status_code == 403
|
|
||||||
|
|
||||||
def test_custom_message(self) -> None:
|
|
||||||
exc = ForbiddenError(message="Access denied")
|
|
||||||
assert exc.message == "Access denied"
|
|
||||||
assert exc.status_code == 403
|
|
||||||
|
|
||||||
def test_is_subclass_of_app_exception(self) -> None:
|
|
||||||
exc = ForbiddenError()
|
|
||||||
assert isinstance(exc, AppException)
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
@@ -8,22 +8,22 @@ from app.main import app_factory, lifespan, main
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_lifespan() -> None:
|
async def test_lifespan():
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
assert isinstance(lifespan, asynccontextmanager(lifespan).__class__) # type: ignore[arg-type]
|
assert isinstance(lifespan, asynccontextmanager(lifespan).__class__)
|
||||||
|
|
||||||
async with lifespan(app):
|
async with lifespan(app):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def test_app_factory() -> None:
|
def test_app_factory():
|
||||||
app = app_factory()
|
app = app_factory()
|
||||||
assert isinstance(app, FastAPI)
|
assert isinstance(app, FastAPI)
|
||||||
assert app.router.lifespan_context == lifespan
|
assert app.router.lifespan_context == lifespan
|
||||||
|
|
||||||
|
|
||||||
@patch("app.main.uvicorn.run")
|
@patch("app.main.uvicorn.run")
|
||||||
def test_main(mock_uvicorn_run: Mock) -> None:
|
def test_main(mock_uvicorn_run):
|
||||||
main()
|
main()
|
||||||
mock_uvicorn_run.assert_called_once_with(
|
mock_uvicorn_run.assert_called_once_with(
|
||||||
app_factory,
|
app_factory,
|
||||||
|
|||||||
Reference in New Issue
Block a user