Compare commits
12 Commits
0553a1fc53
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a94e29d99c | ||
|
|
81e48c73ca | ||
|
|
a3f78dc801 | ||
|
|
07309013d7 | ||
|
|
846fc31190 | ||
|
|
ff7a67cb58 | ||
|
|
0760a8284d | ||
|
|
ce4d0c7b0d | ||
|
|
4ceb8ad98c | ||
|
|
f8aafb250d | ||
|
|
4385d20ca6 | ||
|
|
1a36907f10 |
2
.github/workflows/README.md
vendored
2
.github/workflows/README.md
vendored
@@ -41,7 +41,7 @@ To enable CI/CD workflows:
|
||||
- Runs on: Push to main/develop, PRs affecting frontend code
|
||||
- Tests: Frontend unit tests (Jest)
|
||||
- Coverage: Uploads to Codecov
|
||||
- Fast: Uses npm cache
|
||||
- Fast: Uses bun cache
|
||||
|
||||
### `e2e-tests.yml`
|
||||
- Runs on: All pushes and PRs
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -187,7 +187,7 @@ coverage.xml
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
backend/.benchmarks
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
30
AGENTS.md
30
AGENTS.md
@@ -13,10 +13,10 @@ uv run uvicorn app.main:app --reload # Start dev server
|
||||
|
||||
# Frontend (Node.js)
|
||||
cd frontend
|
||||
npm install # Install dependencies
|
||||
npm run dev # Start dev server
|
||||
npm run generate:api # Generate API client from OpenAPI
|
||||
npm run test:e2e # Run E2E tests
|
||||
bun install # Install dependencies
|
||||
bun run dev # Start dev server
|
||||
bun run generate:api # Generate API client from OpenAPI
|
||||
bun run test:e2e # Run E2E tests
|
||||
```
|
||||
|
||||
**Access points:**
|
||||
@@ -37,7 +37,7 @@ Default superuser (change in production):
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # API routes (auth, users, organizations, admin)
|
||||
│ │ ├── core/ # Core functionality (auth, config, database)
|
||||
│ │ ├── crud/ # Database CRUD operations
|
||||
│ │ ├── repositories/ # Repository pattern (database operations)
|
||||
│ │ ├── models/ # SQLAlchemy ORM models
|
||||
│ │ ├── schemas/ # Pydantic request/response schemas
|
||||
│ │ ├── services/ # Business logic layer
|
||||
@@ -113,7 +113,7 @@ OAUTH_ISSUER=https://api.yourdomain.com # JWT issuer URL (must be HTTPS in
|
||||
### Database Pattern
|
||||
- **Async SQLAlchemy 2.0** with PostgreSQL
|
||||
- **Connection pooling**: 20 base connections, 50 max overflow
|
||||
- **CRUD base class**: `crud/base.py` with common operations
|
||||
- **Repository base class**: `repositories/base.py` with common operations
|
||||
- **Migrations**: Alembic with helper script `migrate.py`
|
||||
- `python migrate.py auto "message"` - Generate and apply
|
||||
- `python migrate.py list` - View history
|
||||
@@ -121,7 +121,7 @@ OAUTH_ISSUER=https://api.yourdomain.com # JWT issuer URL (must be HTTPS in
|
||||
### Frontend State Management
|
||||
- **Zustand stores**: Lightweight state management
|
||||
- **TanStack Query**: API data fetching/caching
|
||||
- **Auto-generated client**: From OpenAPI spec via `npm run generate:api`
|
||||
- **Auto-generated client**: From OpenAPI spec via `bun run generate:api`
|
||||
- **Dependency Injection**: ALWAYS use `useAuth()` from `AuthContext`, NEVER import `useAuthStore` directly
|
||||
|
||||
### Internationalization (i18n)
|
||||
@@ -165,14 +165,14 @@ Permission dependencies in `api/dependencies/permissions.py`:
|
||||
**Frontend Unit Tests (Jest):**
|
||||
- 97% coverage
|
||||
- Component, hook, and utility testing
|
||||
- Run: `npm test`
|
||||
- Coverage: `npm run test:coverage`
|
||||
- Run: `bun run test`
|
||||
- Coverage: `bun run test:coverage`
|
||||
|
||||
**Frontend E2E Tests (Playwright):**
|
||||
- 56 passing, 1 skipped (zero flaky tests)
|
||||
- Complete user flows (auth, navigation, settings)
|
||||
- Run: `npm run test:e2e`
|
||||
- UI mode: `npm run test:e2e:ui`
|
||||
- Run: `bun run test:e2e`
|
||||
- UI mode: `bun run test:e2e:ui`
|
||||
|
||||
### Development Tooling
|
||||
|
||||
@@ -222,11 +222,11 @@ NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
||||
### Adding a New API Endpoint
|
||||
|
||||
1. **Define schema** in `backend/app/schemas/`
|
||||
2. **Create CRUD operations** in `backend/app/crud/`
|
||||
2. **Create repository** in `backend/app/repositories/`
|
||||
3. **Implement route** in `backend/app/api/routes/`
|
||||
4. **Register router** in `backend/app/api/main.py`
|
||||
5. **Write tests** in `backend/tests/api/`
|
||||
6. **Generate frontend client**: `npm run generate:api`
|
||||
6. **Generate frontend client**: `bun run generate:api`
|
||||
|
||||
### Database Migrations
|
||||
|
||||
@@ -243,7 +243,7 @@ python migrate.py auto "description" # Generate + apply
|
||||
2. **Follow design system** (see `frontend/docs/design-system/`)
|
||||
3. **Use dependency injection** for auth (`useAuth()` not `useAuthStore`)
|
||||
4. **Write tests** in `frontend/tests/` or `__tests__/`
|
||||
5. **Run type check**: `npm run type-check`
|
||||
5. **Run type check**: `bun run type-check`
|
||||
|
||||
## Security Features
|
||||
|
||||
@@ -289,7 +289,7 @@ docker-compose exec backend python -c "from app.init_db import init_db; import a
|
||||
- Authentication system (JWT with refresh tokens, OAuth/social login)
|
||||
- **OAuth Provider Mode (MCP-ready)**: Full OAuth 2.0 Authorization Server
|
||||
- Session management (device tracking, revocation)
|
||||
- User management (CRUD, password change)
|
||||
- User management (full lifecycle, password change)
|
||||
- Organization system (multi-tenant with RBAC)
|
||||
- Admin panel (user/org management, bulk operations)
|
||||
- **Internationalization (i18n)** with English and Italian
|
||||
|
||||
20
CLAUDE.md
20
CLAUDE.md
@@ -43,7 +43,7 @@ EOF
|
||||
- Check current state: `python migrate.py current`
|
||||
|
||||
**Frontend API Client Generation:**
|
||||
- Run `npm run generate:api` after backend schema changes
|
||||
- Run `bun run generate:api` after backend schema changes
|
||||
- Client is auto-generated from OpenAPI spec
|
||||
- Located in `frontend/src/lib/api/generated/`
|
||||
- NEVER manually edit generated files
|
||||
@@ -51,8 +51,8 @@ EOF
|
||||
**Testing Commands:**
|
||||
- Backend unit/integration: `IS_TEST=True uv run pytest` (always prefix with `IS_TEST=True`)
|
||||
- Backend E2E (requires Docker): `make test-e2e`
|
||||
- Frontend unit: `npm test`
|
||||
- Frontend E2E: `npm run test:e2e`
|
||||
- Frontend unit: `bun run test`
|
||||
- Frontend E2E: `bun run test:e2e`
|
||||
- Use `make test` or `make test-cov` in backend for convenience
|
||||
|
||||
**Security & Quality Commands (Backend):**
|
||||
@@ -148,7 +148,7 @@ async def mock_commit():
|
||||
with patch.object(session, 'commit', side_effect=mock_commit):
|
||||
with patch.object(session, 'rollback', new_callable=AsyncMock) as mock_rollback:
|
||||
with pytest.raises(OperationalError):
|
||||
await crud_method(session, obj_in=data)
|
||||
await repo_method(session, obj_in=data)
|
||||
mock_rollback.assert_called_once()
|
||||
```
|
||||
|
||||
@@ -171,10 +171,10 @@ with patch.object(session, 'commit', side_effect=mock_commit):
|
||||
### Common Workflows Guidance
|
||||
|
||||
**When Adding a New Feature:**
|
||||
1. Start with backend schema and CRUD
|
||||
1. Start with backend schema and repository
|
||||
2. Implement API route with proper authorization
|
||||
3. Write backend tests (aim for >90% coverage)
|
||||
4. Generate frontend API client: `npm run generate:api`
|
||||
4. Generate frontend API client: `bun run generate:api`
|
||||
5. Implement frontend components
|
||||
6. Write frontend unit tests
|
||||
7. Add E2E tests for critical flows
|
||||
@@ -187,8 +187,8 @@ with patch.object(session, 'commit', side_effect=mock_commit):
|
||||
|
||||
**When Debugging:**
|
||||
- Backend: Check `IS_TEST=True` environment variable is set
|
||||
- Frontend: Run `npm run type-check` first
|
||||
- E2E: Use `npm run test:e2e:debug` for step-by-step debugging
|
||||
- Frontend: Run `bun run type-check` first
|
||||
- E2E: Use `bun run test:e2e:debug` for step-by-step debugging
|
||||
- Check logs: Backend has detailed error logging
|
||||
|
||||
**Demo Mode (Frontend-Only Showcase):**
|
||||
@@ -196,7 +196,7 @@ with patch.object(session, 'commit', side_effect=mock_commit):
|
||||
- Uses MSW (Mock Service Worker) to intercept API calls in browser
|
||||
- Zero backend required - perfect for Vercel deployments
|
||||
- **Fully Automated**: MSW handlers auto-generated from OpenAPI spec
|
||||
- Run `npm run generate:api` → updates both API client AND MSW handlers
|
||||
- Run `bun run generate:api` → updates both API client AND MSW handlers
|
||||
- No manual synchronization needed!
|
||||
- Demo credentials (any password ≥8 chars works):
|
||||
- User: `demo@example.com` / `DemoPass123`
|
||||
@@ -224,7 +224,7 @@ with patch.object(session, 'commit', side_effect=mock_commit):
|
||||
No Claude Code Skills installed yet. To create one, invoke the built-in "skill-creator" skill.
|
||||
|
||||
**Potential skill ideas for this project:**
|
||||
- API endpoint generator workflow (schema → CRUD → route → tests → frontend client)
|
||||
- API endpoint generator workflow (schema → repository → route → tests → frontend client)
|
||||
- Component generator with design system compliance
|
||||
- Database migration troubleshooting helper
|
||||
- Test coverage analyzer and improvement suggester
|
||||
|
||||
@@ -122,20 +122,20 @@ uvicorn app.main:app --reload
|
||||
cd frontend
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
bun install
|
||||
|
||||
# Setup environment
|
||||
cp .env.local.example .env.local
|
||||
|
||||
# Generate API client
|
||||
npm run generate:api
|
||||
bun run generate:api
|
||||
|
||||
# Run tests
|
||||
npm test
|
||||
npm run test:e2e:ui
|
||||
bun run test
|
||||
bun run test:e2e:ui
|
||||
|
||||
# Start dev server
|
||||
npm run dev
|
||||
bun run dev
|
||||
```
|
||||
|
||||
---
|
||||
@@ -204,7 +204,7 @@ export function UserProfile({ userId }: UserProfileProps) {
|
||||
|
||||
### Key Patterns
|
||||
|
||||
- **Backend**: Use CRUD pattern, keep routes thin, business logic in services
|
||||
- **Backend**: Use repository pattern, keep routes thin, business logic in services
|
||||
- **Frontend**: Use React Query for server state, Zustand for client state
|
||||
- **Both**: Handle errors gracefully, log appropriately, write tests
|
||||
|
||||
|
||||
25
Makefile
25
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: help dev dev-full prod down logs logs-dev clean clean-slate drop-db reset-db push-images deploy
|
||||
.PHONY: help dev dev-full prod down logs logs-dev clean clean-slate drop-db reset-db push-images deploy scan-images
|
||||
|
||||
VERSION ?= latest
|
||||
REGISTRY ?= ghcr.io/cardosofelipe/pragma-stack
|
||||
@@ -21,6 +21,7 @@ help:
|
||||
@echo " make prod - Start production stack"
|
||||
@echo " make deploy - Pull and deploy latest images"
|
||||
@echo " make push-images - Build and push images to registry"
|
||||
@echo " make scan-images - Scan production images for CVEs (requires trivy)"
|
||||
@echo " make logs - Follow production container logs"
|
||||
@echo ""
|
||||
@echo "Cleanup:"
|
||||
@@ -89,6 +90,28 @@ push-images:
|
||||
docker push $(REGISTRY)/backend:$(VERSION)
|
||||
docker push $(REGISTRY)/frontend:$(VERSION)
|
||||
|
||||
scan-images:
|
||||
@docker info > /dev/null 2>&1 || (echo "❌ Docker is not running!"; exit 1)
|
||||
@echo "🐳 Building and scanning production images for CVEs..."
|
||||
docker build -t $(REGISTRY)/backend:scan --target production ./backend
|
||||
docker build -t $(REGISTRY)/frontend:scan --target runner ./frontend
|
||||
@echo ""
|
||||
@echo "=== Backend Image Scan ==="
|
||||
@if command -v trivy > /dev/null 2>&1; then \
|
||||
trivy image --severity HIGH,CRITICAL --exit-code 1 $(REGISTRY)/backend:scan; \
|
||||
else \
|
||||
echo "ℹ️ Trivy not found locally, using Docker to run Trivy..."; \
|
||||
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy image --severity HIGH,CRITICAL --exit-code 1 $(REGISTRY)/backend:scan; \
|
||||
fi
|
||||
@echo ""
|
||||
@echo "=== Frontend Image Scan ==="
|
||||
@if command -v trivy > /dev/null 2>&1; then \
|
||||
trivy image --severity HIGH,CRITICAL --exit-code 1 $(REGISTRY)/frontend:scan; \
|
||||
else \
|
||||
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy image --severity HIGH,CRITICAL --exit-code 1 $(REGISTRY)/frontend:scan; \
|
||||
fi
|
||||
@echo "✅ No HIGH/CRITICAL CVEs found in production images!"
|
||||
|
||||
# ============================================================================
|
||||
# Cleanup
|
||||
# ============================================================================
|
||||
|
||||
26
README.md
26
README.md
@@ -58,7 +58,7 @@ Full OAuth 2.0 Authorization Server for Model Context Protocol (MCP) and third-p
|
||||
- User can belong to multiple organizations
|
||||
|
||||
### 🛠️ **Admin Panel**
|
||||
- Complete user management (CRUD, activate/deactivate, bulk operations)
|
||||
- Complete user management (full lifecycle, activate/deactivate, bulk operations)
|
||||
- Organization management (create, edit, delete, member management)
|
||||
- Session monitoring across all users
|
||||
- Real-time statistics dashboard
|
||||
@@ -166,7 +166,7 @@ Full OAuth 2.0 Authorization Server for Model Context Protocol (MCP) and third-p
|
||||
```bash
|
||||
cd frontend
|
||||
echo "NEXT_PUBLIC_DEMO_MODE=true" > .env.local
|
||||
npm run dev
|
||||
bun run dev
|
||||
```
|
||||
|
||||
**Demo Credentials:**
|
||||
@@ -298,17 +298,17 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
cd frontend
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
bun install
|
||||
|
||||
# Setup environment
|
||||
cp .env.local.example .env.local
|
||||
# Edit .env.local with your backend URL
|
||||
|
||||
# Generate API client
|
||||
npm run generate:api
|
||||
bun run generate:api
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Visit http://localhost:3000 to see your app!
|
||||
@@ -322,7 +322,7 @@ Visit http://localhost:3000 to see your app!
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # API routes and dependencies
|
||||
│ │ ├── core/ # Core functionality (auth, config, database)
|
||||
│ │ ├── crud/ # Database operations
|
||||
│ │ ├── repositories/ # Repository pattern (database operations)
|
||||
│ │ ├── models/ # SQLAlchemy models
|
||||
│ │ ├── schemas/ # Pydantic schemas
|
||||
│ │ ├── services/ # Business logic
|
||||
@@ -377,7 +377,7 @@ open htmlcov/index.html
|
||||
```
|
||||
|
||||
**Test types:**
|
||||
- **Unit tests**: CRUD operations, utilities, business logic
|
||||
- **Unit tests**: Repository operations, utilities, business logic
|
||||
- **Integration tests**: API endpoints with database
|
||||
- **Security tests**: JWT algorithm attacks, session hijacking, privilege escalation
|
||||
- **Error handling tests**: Database failures, validation errors
|
||||
@@ -390,13 +390,13 @@ open htmlcov/index.html
|
||||
cd frontend
|
||||
|
||||
# Run unit tests
|
||||
npm test
|
||||
bun run test
|
||||
|
||||
# Run with coverage
|
||||
npm run test:coverage
|
||||
bun run test:coverage
|
||||
|
||||
# Watch mode
|
||||
npm run test:watch
|
||||
bun run test:watch
|
||||
```
|
||||
|
||||
**Test types:**
|
||||
@@ -414,10 +414,10 @@ npm run test:watch
|
||||
cd frontend
|
||||
|
||||
# Run E2E tests
|
||||
npm run test:e2e
|
||||
bun run test:e2e
|
||||
|
||||
# Run E2E tests in UI mode (recommended for development)
|
||||
npm run test:e2e:ui
|
||||
bun run test:e2e:ui
|
||||
|
||||
# Run specific test file
|
||||
npx playwright test auth-login.spec.ts
|
||||
@@ -542,7 +542,7 @@ docker-compose down
|
||||
|
||||
### ✅ Completed
|
||||
- [x] Authentication system (JWT, refresh tokens, session management, OAuth)
|
||||
- [x] User management (CRUD, profile, password change)
|
||||
- [x] User management (full lifecycle, profile, password change)
|
||||
- [x] Organization system with RBAC (Owner, Admin, Member)
|
||||
- [x] Admin panel (users, organizations, sessions, statistics)
|
||||
- [x] **Internationalization (i18n)** with next-intl (English + Italian)
|
||||
|
||||
@@ -11,7 +11,7 @@ omit =
|
||||
app/utils/auth_test_utils.py
|
||||
|
||||
# Async implementations not yet in use
|
||||
app/crud/base_async.py
|
||||
app/repositories/base_async.py
|
||||
app/core/database_async.py
|
||||
|
||||
# CLI scripts - run manually, not tested
|
||||
@@ -23,7 +23,7 @@ omit =
|
||||
app/api/routes/__init__.py
|
||||
app/api/dependencies/__init__.py
|
||||
app/core/__init__.py
|
||||
app/crud/__init__.py
|
||||
app/repositories/__init__.py
|
||||
app/models/__init__.py
|
||||
app/schemas/__init__.py
|
||||
app/services/__init__.py
|
||||
|
||||
@@ -33,11 +33,11 @@ RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
|
||||
# Production stage
|
||||
FROM python:3.12-slim AS production
|
||||
# Production stage — Alpine eliminates glibc CVEs (e.g. CVE-2026-0861)
|
||||
FROM python:3.12-alpine AS production
|
||||
|
||||
# Create non-root user
|
||||
RUN groupadd -r appuser && useradd -r -g appuser appuser
|
||||
RUN addgroup -S appuser && adduser -S -G appuser appuser
|
||||
|
||||
WORKDIR /app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
@@ -48,18 +48,18 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
UV_NO_CACHE=1
|
||||
|
||||
# Install system dependencies and uv
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends postgresql-client curl ca-certificates && \
|
||||
RUN apk add --no-cache postgresql-client curl ca-certificates && \
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
||||
mv /root/.local/bin/uv* /usr/local/bin/ && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
mv /root/.local/bin/uv* /usr/local/bin/
|
||||
|
||||
# Copy dependency files
|
||||
COPY pyproject.toml uv.lock ./
|
||||
|
||||
# Install only production dependencies using uv (no dev dependencies)
|
||||
RUN uv sync --frozen --no-dev
|
||||
# Install build dependencies, compile Python packages, then remove build deps
|
||||
RUN apk add --no-cache --virtual .build-deps \
|
||||
gcc g++ musl-dev python3-dev linux-headers libffi-dev openssl-dev && \
|
||||
uv sync --frozen --no-dev && \
|
||||
apk del .build-deps
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: help lint lint-fix format format-check type-check test test-cov validate clean install-dev sync check-docker install-e2e test-e2e test-e2e-schema test-all dep-audit license-check audit validate-all check
|
||||
.PHONY: help lint lint-fix format format-check type-check test test-cov validate clean install-dev sync check-docker install-e2e test-e2e test-e2e-schema test-all dep-audit license-check audit validate-all check benchmark benchmark-check benchmark-save scan-image test-api-security
|
||||
|
||||
# Prevent a stale VIRTUAL_ENV in the caller's shell from confusing uv
|
||||
unexport VIRTUAL_ENV
|
||||
@@ -18,12 +18,18 @@ help:
|
||||
@echo " make format - Format code with Ruff"
|
||||
@echo " make format-check - Check if code is formatted"
|
||||
@echo " make type-check - Run pyright type checking"
|
||||
@echo " make validate - Run all checks (lint + format + types)"
|
||||
@echo " make validate - Run all checks (lint + format + types + schema fuzz)"
|
||||
@echo ""
|
||||
@echo "Performance:"
|
||||
@echo " make benchmark - Run performance benchmarks"
|
||||
@echo " make benchmark-save - Run benchmarks and save as baseline"
|
||||
@echo " make benchmark-check - Run benchmarks and compare against baseline"
|
||||
@echo ""
|
||||
@echo "Security & Audit:"
|
||||
@echo " make dep-audit - Scan dependencies for known vulnerabilities"
|
||||
@echo " make license-check - Check dependency license compliance"
|
||||
@echo " make audit - Run all security audits (deps + licenses)"
|
||||
@echo " make scan-image - Scan Docker image for CVEs (requires trivy)"
|
||||
@echo " make validate-all - Run all quality + security checks"
|
||||
@echo " make check - Full pipeline: quality + security + tests"
|
||||
@echo ""
|
||||
@@ -77,28 +83,43 @@ type-check:
|
||||
@echo "🔎 Running pyright type checking..."
|
||||
@uv run pyright app/
|
||||
|
||||
validate: lint format-check type-check
|
||||
validate: lint format-check type-check test-api-security
|
||||
@echo "✅ All quality checks passed!"
|
||||
|
||||
# API Security Testing (Schemathesis property-based fuzzing)
|
||||
test-api-security: check-docker
|
||||
@echo "🔐 Running Schemathesis API security fuzzing..."
|
||||
@IS_TEST=True PYTHONPATH=. uv run pytest tests/e2e/ -v -m "schemathesis" --tb=short -n 0
|
||||
@echo "✅ API schema security tests passed!"
|
||||
|
||||
# ============================================================================
|
||||
# Security & Audit
|
||||
# ============================================================================
|
||||
|
||||
dep-audit:
|
||||
@echo "🔒 Scanning dependencies for known vulnerabilities..."
|
||||
@# CVE-2024-23342: ecdsa timing attack via python-jose (transitive). No fix available.
|
||||
@# We only use HS256 (not ECDSA signing), so this is not exploitable. Track python-jose replacement separately.
|
||||
@uv run pip-audit --desc --skip-editable --ignore-vuln CVE-2024-23342
|
||||
@uv run pip-audit --desc --skip-editable
|
||||
@echo "✅ No known vulnerabilities found!"
|
||||
|
||||
license-check:
|
||||
@echo "📜 Checking dependency license compliance..."
|
||||
@uv run pip-licenses --fail-on="GPL-3.0-or-later;AGPL-3.0-or-later" --format=plain
|
||||
@uv run pip-licenses --fail-on="GPL-3.0-or-later;AGPL-3.0-or-later" --format=plain > /dev/null
|
||||
@echo "✅ All dependency licenses are compliant!"
|
||||
|
||||
audit: dep-audit license-check
|
||||
@echo "✅ All security audits passed!"
|
||||
|
||||
scan-image: check-docker
|
||||
@echo "🐳 Scanning Docker image for OS-level CVEs with Trivy..."
|
||||
@docker build -t pragma-backend:scan -q --target production .
|
||||
@if command -v trivy > /dev/null 2>&1; then \
|
||||
trivy image --severity HIGH,CRITICAL --exit-code 1 pragma-backend:scan; \
|
||||
else \
|
||||
echo "ℹ️ Trivy not found locally, using Docker to run Trivy..."; \
|
||||
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy image --severity HIGH,CRITICAL --exit-code 1 pragma-backend:scan; \
|
||||
fi
|
||||
@echo "✅ No HIGH/CRITICAL CVEs found in Docker image!"
|
||||
|
||||
validate-all: validate audit
|
||||
@echo "✅ All quality + security checks passed!"
|
||||
|
||||
@@ -150,6 +171,31 @@ test-e2e-schema: check-docker
|
||||
@echo "🧪 Running Schemathesis API schema tests..."
|
||||
@IS_TEST=True PYTHONPATH=. uv run pytest tests/e2e/ -v -m "schemathesis" --tb=short -n 0
|
||||
|
||||
# ============================================================================
|
||||
# Performance Benchmarks
|
||||
# ============================================================================
|
||||
|
||||
benchmark:
|
||||
@echo "⏱️ Running performance benchmarks..."
|
||||
@IS_TEST=True PYTHONPATH=. uv run pytest tests/benchmarks/ -v --benchmark-only --benchmark-sort=mean -p no:xdist --override-ini='addopts='
|
||||
|
||||
benchmark-save:
|
||||
@echo "⏱️ Running benchmarks and saving baseline..."
|
||||
@IS_TEST=True PYTHONPATH=. uv run pytest tests/benchmarks/ -v --benchmark-only --benchmark-save=baseline --benchmark-sort=mean -p no:xdist --override-ini='addopts='
|
||||
@echo "✅ Benchmark baseline saved to .benchmarks/"
|
||||
|
||||
benchmark-check:
|
||||
@echo "⏱️ Running benchmarks and comparing against baseline..."
|
||||
@if find .benchmarks -name '*_baseline*' -print -quit 2>/dev/null | grep -q .; then \
|
||||
IS_TEST=True PYTHONPATH=. uv run pytest tests/benchmarks/ -v --benchmark-only --benchmark-compare=0001_baseline --benchmark-sort=mean --benchmark-compare-fail=mean:200% -p no:xdist --override-ini='addopts='; \
|
||||
echo "✅ No performance regressions detected!"; \
|
||||
else \
|
||||
echo "⚠️ No benchmark baseline found. Run 'make benchmark-save' first to create one."; \
|
||||
echo " Running benchmarks without comparison..."; \
|
||||
IS_TEST=True PYTHONPATH=. uv run pytest tests/benchmarks/ -v --benchmark-only --benchmark-save=baseline --benchmark-sort=mean -p no:xdist --override-ini='addopts='; \
|
||||
echo "✅ Benchmark baseline created. Future runs of 'make benchmark-check' will compare against it."; \
|
||||
fi
|
||||
|
||||
test-all:
|
||||
@echo "🧪 Running ALL tests (unit + E2E)..."
|
||||
@$(MAKE) test
|
||||
|
||||
@@ -264,7 +264,7 @@ app/
|
||||
│ ├── database.py # Database engine setup
|
||||
│ ├── auth.py # JWT token handling
|
||||
│ └── exceptions.py # Custom exceptions
|
||||
├── crud/ # Database operations
|
||||
├── repositories/ # Repository pattern (database operations)
|
||||
├── models/ # SQLAlchemy ORM models
|
||||
├── schemas/ # Pydantic request/response schemas
|
||||
├── services/ # Business logic layer
|
||||
@@ -462,7 +462,7 @@ See [docs/FEATURE_EXAMPLE.md](docs/FEATURE_EXAMPLE.md) for step-by-step guide.
|
||||
|
||||
Quick overview:
|
||||
1. Create Pydantic schemas in `app/schemas/`
|
||||
2. Create CRUD operations in `app/crud/`
|
||||
2. Create repository in `app/repositories/`
|
||||
3. Create route in `app/api/routes/`
|
||||
4. Register router in `app/api/main.py`
|
||||
5. Write tests in `tests/api/`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
User management endpoints for CRUD operations.
|
||||
User management endpoints for database operations.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
import bcrypt
|
||||
import jwt
|
||||
from jwt.exceptions import (
|
||||
ExpiredSignatureError,
|
||||
InvalidTokenError,
|
||||
MissingRequiredClaimError,
|
||||
)
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.core.config import settings
|
||||
from app.schemas.users import TokenData, TokenPayload
|
||||
|
||||
# Suppress passlib bcrypt warnings about ident
|
||||
logging.getLogger("passlib").setLevel(logging.ERROR)
|
||||
|
||||
# Password hashing context
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
# Custom exceptions for auth
|
||||
class AuthError(Exception):
|
||||
@@ -37,13 +35,16 @@ class TokenMissingClaimError(AuthError):
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against a hash."""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
"""Verify a password against a bcrypt hash."""
|
||||
return bcrypt.checkpw(
|
||||
plain_password.encode("utf-8"), hashed_password.encode("utf-8")
|
||||
)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Generate a password hash."""
|
||||
return pwd_context.hash(password)
|
||||
"""Generate a bcrypt password hash."""
|
||||
salt = bcrypt.gensalt()
|
||||
return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8")
|
||||
|
||||
|
||||
async def verify_password_async(plain_password: str, hashed_password: str) -> bool:
|
||||
@@ -60,9 +61,9 @@ async def verify_password_async(plain_password: str, hashed_password: str) -> bo
|
||||
Returns:
|
||||
True if password matches, False otherwise
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
None, partial(pwd_context.verify, plain_password, hashed_password)
|
||||
None, partial(verify_password, plain_password, hashed_password)
|
||||
)
|
||||
|
||||
|
||||
@@ -80,8 +81,8 @@ async def get_password_hash_async(password: str) -> str:
|
||||
Returns:
|
||||
Hashed password string
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, pwd_context.hash, password)
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, get_password_hash, password)
|
||||
|
||||
|
||||
def create_access_token(
|
||||
@@ -121,11 +122,7 @@ def create_access_token(
|
||||
to_encode.update(claims)
|
||||
|
||||
# Create the JWT
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
|
||||
)
|
||||
|
||||
return encoded_jwt
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(
|
||||
@@ -154,11 +151,7 @@ def create_refresh_token(
|
||||
"type": "refresh",
|
||||
}
|
||||
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
|
||||
)
|
||||
|
||||
return encoded_jwt
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str, verify_type: str | None = None) -> TokenPayload:
|
||||
@@ -198,7 +191,7 @@ def decode_token(token: str, verify_type: str | None = None) -> TokenPayload:
|
||||
|
||||
# Reject weak or unexpected algorithms
|
||||
# NOTE: These are defensive checks that provide defense-in-depth.
|
||||
# The python-jose library rejects these tokens BEFORE we reach here,
|
||||
# PyJWT rejects these tokens BEFORE we reach here,
|
||||
# but we keep these checks in case the library changes or is misconfigured.
|
||||
# Coverage: Marked as pragma since library catches first (see tests/core/test_auth_security.py)
|
||||
if token_algorithm == "NONE": # pragma: no cover
|
||||
@@ -219,10 +212,11 @@ def decode_token(token: str, verify_type: str | None = None) -> TokenPayload:
|
||||
token_data = TokenPayload(**payload)
|
||||
return token_data
|
||||
|
||||
except JWTError as e:
|
||||
# Check if the error is due to an expired token
|
||||
if "expired" in str(e).lower():
|
||||
except ExpiredSignatureError:
|
||||
raise TokenExpiredError("Token has expired")
|
||||
except MissingRequiredClaimError as e:
|
||||
raise TokenMissingClaimError(f"Token missing required claim: {e}")
|
||||
except InvalidTokenError:
|
||||
raise TokenInvalidError("Invalid authentication token")
|
||||
except ValidationError:
|
||||
raise TokenInvalidError("Invalid token payload")
|
||||
|
||||
@@ -128,8 +128,8 @@ async def async_transaction_scope() -> AsyncGenerator[AsyncSession, None]:
|
||||
|
||||
Usage:
|
||||
async with async_transaction_scope() as db:
|
||||
user = await user_crud.create(db, obj_in=user_create)
|
||||
profile = await profile_crud.create(db, obj_in=profile_create)
|
||||
user = await user_repo.create(db, obj_in=user_create)
|
||||
profile = await profile_repo.create(db, obj_in=profile_create)
|
||||
# Both operations committed together
|
||||
"""
|
||||
async with SessionLocal() as session:
|
||||
|
||||
@@ -19,7 +19,7 @@ from app.core.database import SessionLocal, engine
|
||||
from app.models.organization import Organization
|
||||
from app.models.user import User
|
||||
from app.models.user_organization import UserOrganization
|
||||
from app.repositories.user import user_repo as user_crud
|
||||
from app.repositories.user import user_repo as user_repo
|
||||
from app.schemas.users import UserCreate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -51,7 +51,7 @@ async def init_db() -> User | None:
|
||||
async with SessionLocal() as session:
|
||||
try:
|
||||
# Check if superuser already exists
|
||||
existing_user = await user_crud.get_by_email(session, email=superuser_email)
|
||||
existing_user = await user_repo.get_by_email(session, email=superuser_email)
|
||||
|
||||
if existing_user:
|
||||
logger.info("Superuser already exists: %s", existing_user.email)
|
||||
@@ -66,7 +66,7 @@ async def init_db() -> User | None:
|
||||
is_superuser=True,
|
||||
)
|
||||
|
||||
user = await user_crud.create(session, obj_in=user_in)
|
||||
user = await user_repo.create(session, obj_in=user_in)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
@@ -136,7 +136,7 @@ async def load_demo_data(session):
|
||||
|
||||
# Create Users
|
||||
for user_data in data.get("users", []):
|
||||
existing_user = await user_crud.get_by_email(
|
||||
existing_user = await user_repo.get_by_email(
|
||||
session, email=user_data["email"]
|
||||
)
|
||||
if not existing_user:
|
||||
@@ -149,7 +149,7 @@ async def load_demo_data(session):
|
||||
is_superuser=user_data["is_superuser"],
|
||||
is_active=user_data.get("is_active", True),
|
||||
)
|
||||
user = await user_crud.create(session, obj_in=user_in)
|
||||
user = await user_repo.create(session, obj_in=user_in)
|
||||
|
||||
# Randomize created_at for demo data (last 30 days)
|
||||
# This makes the charts look more realistic
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# app/repositories/base.py
|
||||
"""
|
||||
Base repository class for async CRUD operations using SQLAlchemy 2.0 async patterns.
|
||||
Base repository class for async database operations using SQLAlchemy 2.0 async patterns.
|
||||
|
||||
Provides reusable create, read, update, and delete operations for all models.
|
||||
"""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# app/repositories/oauth_account.py
|
||||
"""Repository for OAuthAccount model async CRUD operations."""
|
||||
"""Repository for OAuthAccount model async database operations."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# app/repositories/oauth_client.py
|
||||
"""Repository for OAuthClient model async CRUD operations."""
|
||||
"""Repository for OAuthClient model async database operations."""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# app/repositories/oauth_state.py
|
||||
"""Repository for OAuthState model async CRUD operations."""
|
||||
"""Repository for OAuthState model async database operations."""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# app/repositories/organization.py
|
||||
"""Repository for Organization model async CRUD operations using SQLAlchemy 2.0 patterns."""
|
||||
"""Repository for Organization model async database operations using SQLAlchemy 2.0 patterns."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# app/repositories/session.py
|
||||
"""Repository for UserSession model async CRUD operations using SQLAlchemy 2.0 patterns."""
|
||||
"""Repository for UserSession model async database operations using SQLAlchemy 2.0 patterns."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# app/repositories/user.py
|
||||
"""Repository for User model async CRUD operations using SQLAlchemy 2.0 patterns."""
|
||||
"""Repository for User model async database operations using SQLAlchemy 2.0 patterns."""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
@@ -25,8 +25,8 @@ from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from jose.exceptions import ExpiredSignatureError
|
||||
import jwt
|
||||
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
@@ -704,7 +704,7 @@ async def revoke_token(
|
||||
"Revoked refresh token via access token JTI %s...", jti[:8]
|
||||
)
|
||||
return True
|
||||
except JWTError:
|
||||
except InvalidTokenError:
|
||||
pass
|
||||
except Exception: # noqa: S110 - Intentional: invalid JWT not an error
|
||||
pass
|
||||
@@ -827,7 +827,7 @@ async def introspect_token(
|
||||
}
|
||||
except ExpiredSignatureError:
|
||||
return {"active": False}
|
||||
except JWTError:
|
||||
except InvalidTokenError:
|
||||
pass
|
||||
except Exception: # noqa: S110 - Intentional: invalid JWT falls through to refresh token check
|
||||
pass
|
||||
|
||||
@@ -537,8 +537,9 @@ class OAuthService:
|
||||
AuthenticationError: If verification fails
|
||||
"""
|
||||
import httpx
|
||||
from jose import jwt as jose_jwt
|
||||
from jose.exceptions import JWTError
|
||||
import jwt as pyjwt
|
||||
from jwt.algorithms import RSAAlgorithm
|
||||
from jwt.exceptions import InvalidTokenError
|
||||
|
||||
try:
|
||||
# Fetch Google's public keys (JWKS)
|
||||
@@ -552,24 +553,27 @@ class OAuthService:
|
||||
jwks = jwks_response.json()
|
||||
|
||||
# Get the key ID from the token header
|
||||
unverified_header = jose_jwt.get_unverified_header(id_token)
|
||||
unverified_header = pyjwt.get_unverified_header(id_token)
|
||||
kid = unverified_header.get("kid")
|
||||
if not kid:
|
||||
raise AuthenticationError("ID token missing key ID (kid)")
|
||||
|
||||
# Find the matching public key
|
||||
public_key = None
|
||||
jwk_data = None
|
||||
for key in jwks.get("keys", []):
|
||||
if key.get("kid") == kid:
|
||||
public_key = key
|
||||
jwk_data = key
|
||||
break
|
||||
|
||||
if not public_key:
|
||||
if not jwk_data:
|
||||
raise AuthenticationError("ID token signed with unknown key")
|
||||
|
||||
# Convert JWK to a public key object for PyJWT
|
||||
public_key = RSAAlgorithm.from_jwk(jwk_data)
|
||||
|
||||
# Verify the token signature and decode claims
|
||||
# jose library will verify signature against the JWK
|
||||
payload = jose_jwt.decode(
|
||||
# PyJWT will verify signature against the RSA public key
|
||||
payload = pyjwt.decode(
|
||||
id_token,
|
||||
public_key,
|
||||
algorithms=["RS256"], # Google uses RS256
|
||||
@@ -597,7 +601,7 @@ class OAuthService:
|
||||
logger.debug("Google ID token verified successfully")
|
||||
return payload
|
||||
|
||||
except JWTError as e:
|
||||
except InvalidTokenError as e:
|
||||
logger.warning("Google ID token verification failed: %s", e)
|
||||
raise AuthenticationError("Invalid ID token signature")
|
||||
except httpx.HTTPError as e:
|
||||
|
||||
@@ -8,7 +8,7 @@ import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.core.database import SessionLocal
|
||||
from app.repositories.session import session_repo as session_crud
|
||||
from app.repositories.session import session_repo as session_repo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,8 +32,8 @@ async def cleanup_expired_sessions(keep_days: int = 30) -> int:
|
||||
|
||||
async with SessionLocal() as db:
|
||||
try:
|
||||
# Use CRUD method to cleanup
|
||||
count = await session_crud.cleanup_expired(db, keep_days=keep_days)
|
||||
# Use repository method to cleanup
|
||||
count = await session_repo.cleanup_expired(db, keep_days=keep_days)
|
||||
|
||||
logger.info("Session cleanup complete: %s sessions deleted", count)
|
||||
|
||||
|
||||
@@ -79,12 +79,13 @@ This FastAPI backend application follows a **clean layered architecture** patter
|
||||
|
||||
### Authentication & Security
|
||||
|
||||
- **python-jose**: JWT token generation and validation
|
||||
- Cryptographic signing
|
||||
- **PyJWT**: JWT token generation and validation
|
||||
- Cryptographic signing (HS256, RS256)
|
||||
- Token expiration handling
|
||||
- Claims validation
|
||||
- JWK support for Google ID token verification
|
||||
|
||||
- **passlib + bcrypt**: Password hashing
|
||||
- **bcrypt**: Password hashing
|
||||
- Industry-standard bcrypt algorithm
|
||||
- Configurable cost factor
|
||||
- Salt generation
|
||||
@@ -1168,6 +1169,8 @@ app.add_middleware(
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
> 📖 For the full benchmarking guide (how to run, read results, write new benchmarks, and manage baselines), see **[BENCHMARKS.md](BENCHMARKS.md)**.
|
||||
|
||||
### Database Connection Pooling
|
||||
|
||||
- Pool size: 20 connections
|
||||
|
||||
311
backend/docs/BENCHMARKS.md
Normal file
311
backend/docs/BENCHMARKS.md
Normal file
@@ -0,0 +1,311 @@
|
||||
# Performance Benchmarks Guide
|
||||
|
||||
Automated performance benchmarking infrastructure using **pytest-benchmark** to detect latency regressions in critical API endpoints.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Why Benchmark?](#why-benchmark)
|
||||
- [Quick Start](#quick-start)
|
||||
- [How It Works](#how-it-works)
|
||||
- [Understanding Results](#understanding-results)
|
||||
- [Test Organization](#test-organization)
|
||||
- [Writing Benchmark Tests](#writing-benchmark-tests)
|
||||
- [Baseline Management](#baseline-management)
|
||||
- [CI/CD Integration](#cicd-integration)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Why Benchmark?
|
||||
|
||||
Performance regressions are silent bugs — they don't break tests or cause errors, but they degrade the user experience over time. Common causes include:
|
||||
|
||||
- **Unintended N+1 queries** after adding a relationship
|
||||
- **Heavier serialization** after adding new fields to a response model
|
||||
- **Middleware overhead** from new security headers or logging
|
||||
- **Dependency upgrades** that introduce slower code paths
|
||||
|
||||
Without automated benchmarks, these regressions go unnoticed until users complain. Performance benchmarks serve as an **early warning system** — they measure endpoint latency on every run and flag significant deviations from an established baseline.
|
||||
|
||||
### What benchmarks give you
|
||||
|
||||
| Benefit | Description |
|
||||
|---------|-------------|
|
||||
| **Regression detection** | Automatically flags when an endpoint becomes significantly slower |
|
||||
| **Baseline tracking** | Stores known-good performance numbers for comparison |
|
||||
| **Confidence in refactors** | Verify that code changes don't degrade response times |
|
||||
| **Visibility** | Makes performance a first-class, measurable quality attribute |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Run benchmarks (no comparison, just see current numbers)
|
||||
make benchmark
|
||||
|
||||
# Save current results as the baseline
|
||||
make benchmark-save
|
||||
|
||||
# Run benchmarks and compare against the saved baseline
|
||||
make benchmark-check
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
The benchmarking system has three layers:
|
||||
|
||||
### 1. pytest-benchmark integration
|
||||
|
||||
[pytest-benchmark](https://pytest-benchmark.readthedocs.io/) is a pytest plugin that provides a `benchmark` fixture. It handles:
|
||||
|
||||
- **Calibration**: Automatically determines how many iterations to run for statistical significance
|
||||
- **Timing**: Uses `time.perf_counter` for high-resolution measurements
|
||||
- **Statistics**: Computes min, max, mean, median, standard deviation, IQR, and outlier detection
|
||||
- **Comparison**: Compares current results against saved baselines and flags regressions
|
||||
|
||||
### 2. Benchmark types
|
||||
|
||||
The test suite includes two categories of performance tests:
|
||||
|
||||
| Type | How it works | Examples |
|
||||
|------|-------------|----------|
|
||||
| **pytest-benchmark tests** | Uses the `benchmark` fixture for precise, multi-round timing | `test_health_endpoint_performance`, `test_openapi_schema_performance`, `test_password_hashing_performance`, `test_password_verification_performance`, `test_access_token_creation_performance`, `test_refresh_token_creation_performance`, `test_token_decode_performance` |
|
||||
| **Manual latency tests** | Uses `time.perf_counter` with explicit thresholds (for async endpoints that pytest-benchmark doesn't support natively) | `test_login_latency`, `test_get_current_user_latency`, `test_register_latency`, `test_token_refresh_latency`, `test_sessions_list_latency`, `test_user_profile_update_latency` |
|
||||
|
||||
### 3. Regression detection
|
||||
|
||||
When running `make benchmark-check`, the system:
|
||||
|
||||
1. Runs all benchmark tests
|
||||
2. Compares results against the saved baseline (`.benchmarks/` directory)
|
||||
3. **Fails the build** if any test's mean time exceeds **200%** of the baseline (i.e., 3× slower)
|
||||
|
||||
The `200%` threshold in `--benchmark-compare-fail=mean:200%` means "fail if the mean increased by more than 200% relative to the baseline." This is deliberately generous to avoid false positives from normal run-to-run variance while still catching real regressions.
|
||||
|
||||
---
|
||||
|
||||
## Understanding Results
|
||||
|
||||
A typical benchmark output looks like this:
|
||||
|
||||
```
|
||||
--------------------------------------------------------------------------------------- benchmark: 2 tests --------------------------------------------------------------------------------------
|
||||
Name (time in ms) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations
|
||||
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
test_health_endpoint_performance 0.9841 (1.0) 1.5513 (1.0) 1.1390 (1.0) 0.1098 (1.0) 1.1151 (1.0) 0.1672 (1.0) 39;2 877.9666 (1.0) 133 1
|
||||
test_openapi_schema_performance 1.6523 (1.68) 2.0892 (1.35) 1.7843 (1.57) 0.1553 (1.41) 1.7200 (1.54) 0.1727 (1.03) 2;0 560.4471 (0.64) 10 1
|
||||
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
```
|
||||
|
||||
### Column reference
|
||||
|
||||
| Column | Meaning |
|
||||
|--------|---------|
|
||||
| **Min** | Fastest single execution |
|
||||
| **Max** | Slowest single execution |
|
||||
| **Mean** | Average across all rounds — the primary metric for regression detection |
|
||||
| **StdDev** | How much results vary between rounds (lower = more stable) |
|
||||
| **Median** | Middle value, less sensitive to outliers than mean |
|
||||
| **IQR** | Interquartile range — spread of the middle 50% of results |
|
||||
| **Outliers** | Format `A;B` — A = within 1 StdDev, B = within 1.5 IQR from quartiles |
|
||||
| **OPS** | Operations per second (`1 / Mean`) |
|
||||
| **Rounds** | How many times the test was executed (auto-calibrated) |
|
||||
| **Iterations** | Iterations per round (usually 1 for ms-scale tests) |
|
||||
|
||||
### The ratio numbers `(1.0)`, `(1.68)`, etc.
|
||||
|
||||
These show how each test compares **to the best result in that column**. The fastest test is always `(1.0)`, and others show their relative factor. For example, `(1.68)` means "1.68× slower than the fastest."
|
||||
|
||||
### Color coding
|
||||
|
||||
- **Green**: The fastest (best) value in each column
|
||||
- **Red**: The slowest (worst) value in each column
|
||||
|
||||
This is a **relative ranking within the current run** — red does NOT mean the test failed or that performance is bad. It simply highlights which endpoint is the slower one in the group.
|
||||
|
||||
### What's "normal"?
|
||||
|
||||
For this project's current endpoints:
|
||||
|
||||
| Test | Expected range | Why |
|
||||
|------|---------------|-----|
|
||||
| `GET /health` | ~1–1.5ms | Minimal logic, mocked DB check |
|
||||
| `GET /api/v1/openapi.json` | ~1.5–2.5ms | Serializes entire API schema |
|
||||
| `get_password_hash` | ~200ms | CPU-bound bcrypt hashing |
|
||||
| `verify_password` | ~200ms | CPU-bound bcrypt verification |
|
||||
| `create_access_token` | ~17–20µs | JWT encoding with HMAC-SHA256 |
|
||||
| `create_refresh_token` | ~17–20µs | JWT encoding with HMAC-SHA256 |
|
||||
| `decode_token` | ~20–25µs | JWT decoding and claim validation |
|
||||
| `POST /api/v1/auth/login` | < 500ms threshold | Includes bcrypt password verification |
|
||||
| `POST /api/v1/auth/register` | < 500ms threshold | Includes bcrypt password hashing |
|
||||
| `POST /api/v1/auth/refresh` | < 200ms threshold | Token rotation + DB session update |
|
||||
| `GET /api/v1/users/me` | < 200ms threshold | DB lookup + token validation |
|
||||
| `GET /api/v1/sessions/me` | < 200ms threshold | Session list query + token validation |
|
||||
| `PATCH /api/v1/users/me` | < 200ms threshold | DB update + token validation |
|
||||
|
||||
---
|
||||
|
||||
## Test Organization
|
||||
|
||||
```
|
||||
backend/tests/
|
||||
├── benchmarks/
|
||||
│ └── test_endpoint_performance.py # All performance benchmark tests
|
||||
│
|
||||
backend/.benchmarks/ # Saved baselines (auto-generated)
|
||||
└── Linux-CPython-3.12-64bit/
|
||||
└── 0001_baseline.json # Platform-specific baseline file
|
||||
```
|
||||
|
||||
### Test markers
|
||||
|
||||
All benchmark tests use the `@pytest.mark.benchmark` marker. The `--benchmark-only` flag ensures that only tests using the `benchmark` fixture are executed during benchmark runs, while manual latency tests (async) are skipped.
|
||||
|
||||
---
|
||||
|
||||
## Writing Benchmark Tests
|
||||
|
||||
### Stateless endpoint (using pytest-benchmark fixture)
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
def test_my_endpoint_performance(sync_client, benchmark):
|
||||
"""Benchmark: GET /my-endpoint should respond within acceptable latency."""
|
||||
result = benchmark(sync_client.get, "/my-endpoint")
|
||||
assert result.status_code == 200
|
||||
```
|
||||
|
||||
The `benchmark` fixture handles all timing, calibration, and statistics automatically. Just pass it the callable and arguments.
|
||||
|
||||
### Async / DB-dependent endpoint (manual timing)
|
||||
|
||||
For async endpoints that require database access, use manual timing with an explicit threshold:
|
||||
|
||||
```python
|
||||
import time
|
||||
import pytest
|
||||
|
||||
MAX_RESPONSE_MS = 300
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_my_async_endpoint_latency(client, setup_fixture):
|
||||
"""Performance: endpoint must respond under threshold."""
|
||||
iterations = 5
|
||||
total_ms = 0.0
|
||||
|
||||
for _ in range(iterations):
|
||||
start = time.perf_counter()
|
||||
response = await client.get("/api/v1/my-endpoint")
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
total_ms += elapsed_ms
|
||||
assert response.status_code == 200
|
||||
|
||||
mean_ms = total_ms / iterations
|
||||
assert mean_ms < MAX_RESPONSE_MS, (
|
||||
f"Latency regression: {mean_ms:.1f}ms exceeds {MAX_RESPONSE_MS}ms threshold"
|
||||
)
|
||||
```
|
||||
|
||||
### Guidelines for new benchmarks
|
||||
|
||||
1. **Benchmark critical paths** — endpoints users hit frequently or where latency matters most
|
||||
2. **Mock external dependencies** for stateless tests to isolate endpoint overhead
|
||||
3. **Set generous thresholds** for manual tests — account for CI variability
|
||||
4. **Keep benchmarks fast** — they run on every check, so avoid heavy setup
|
||||
|
||||
---
|
||||
|
||||
## Baseline Management
|
||||
|
||||
### Saving a baseline
|
||||
|
||||
```bash
|
||||
make benchmark-save
|
||||
```
|
||||
|
||||
This runs all benchmarks and saves results to `.benchmarks/<platform>/0001_baseline.json`. The baseline captures:
|
||||
- Mean, min, max, median, stddev for each test
|
||||
- Machine info (CPU, OS, Python version)
|
||||
- Timestamp
|
||||
|
||||
### Comparing against baseline
|
||||
|
||||
```bash
|
||||
make benchmark-check
|
||||
```
|
||||
|
||||
If no baseline exists, this command automatically creates one and prints a warning. On subsequent runs, it compares current results against the saved baseline.
|
||||
|
||||
### When to update the baseline
|
||||
|
||||
- **After intentional performance changes** (e.g., you optimized an endpoint — save the new, faster baseline)
|
||||
- **After infrastructure changes** (e.g., new CI runner, different hardware)
|
||||
- **After adding new benchmark tests** (the new tests need a baseline entry)
|
||||
|
||||
```bash
|
||||
# Update the baseline after intentional changes
|
||||
make benchmark-save
|
||||
```
|
||||
|
||||
### Version control
|
||||
|
||||
The `.benchmarks/` directory can be committed to version control so that CI pipelines can compare against a known-good baseline. However, since benchmark results are machine-specific, you may prefer to generate baselines in CI rather than committing local results.
|
||||
|
||||
---
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
Add benchmark checking to your CI pipeline to catch regressions on every PR:
|
||||
|
||||
```yaml
|
||||
# Example GitHub Actions step
|
||||
- name: Performance regression check
|
||||
run: |
|
||||
cd backend
|
||||
make benchmark-save # Create baseline from main branch
|
||||
# ... apply PR changes ...
|
||||
make benchmark-check # Compare PR against baseline
|
||||
```
|
||||
|
||||
A more robust approach:
|
||||
1. Save the baseline on the `main` branch after each merge
|
||||
2. On PR branches, run `make benchmark-check` against the `main` baseline
|
||||
3. The pipeline fails if any endpoint regresses beyond the 200% threshold
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "No benchmark baseline found" warning
|
||||
|
||||
```
|
||||
⚠️ No benchmark baseline found. Run 'make benchmark-save' first to create one.
|
||||
```
|
||||
|
||||
This means no baseline file exists yet. The command will auto-create one. Future runs of `make benchmark-check` will compare against it.
|
||||
|
||||
### Machine info mismatch warning
|
||||
|
||||
```
|
||||
WARNING: benchmark machine_info is different
|
||||
```
|
||||
|
||||
This is expected when comparing baselines generated on a different machine or OS. The comparison still works, but absolute numbers may differ. Re-save the baseline on the current machine if needed.
|
||||
|
||||
### High variance (large StdDev)
|
||||
|
||||
If StdDev is high relative to the Mean, results may be unreliable. Common causes:
|
||||
- System under load during benchmark run
|
||||
- Garbage collection interference
|
||||
- Thermal throttling
|
||||
|
||||
Try running benchmarks on an idle system or increasing `min_rounds` in `pyproject.toml`.
|
||||
|
||||
### Only 7 of 13 tests run
|
||||
|
||||
The async tests (`test_login_latency`, `test_get_current_user_latency`, `test_register_latency`, `test_token_refresh_latency`, `test_sessions_list_latency`, `test_user_profile_update_latency`) are skipped during `--benchmark-only` runs because they don't use the `benchmark` fixture. They run as part of the normal test suite (`make test`) with manual threshold assertions.
|
||||
@@ -214,7 +214,7 @@ if not user:
|
||||
|
||||
### Error Handling Pattern
|
||||
|
||||
Always follow this pattern in CRUD operations (Async version):
|
||||
Always follow this pattern in repository operations (Async version):
|
||||
|
||||
```python
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError, DataError
|
||||
@@ -427,7 +427,7 @@ backend/app/alembic/versions/
|
||||
|
||||
## Database Operations
|
||||
|
||||
### Async CRUD Pattern
|
||||
### Async Repository Pattern
|
||||
|
||||
**IMPORTANT**: This application uses **async SQLAlchemy** with modern patterns for better performance and testability.
|
||||
|
||||
@@ -567,7 +567,7 @@ async def create_user(
|
||||
**Key Points:**
|
||||
- Route functions must be `async def`
|
||||
- Database parameter is `AsyncSession`
|
||||
- Always `await` CRUD operations
|
||||
- Always `await` repository operations
|
||||
|
||||
#### In Services (Multiple Operations)
|
||||
|
||||
|
||||
@@ -334,14 +334,14 @@ def login(request: Request, credentials: OAuth2PasswordRequestForm):
|
||||
# ❌ WRONG - Returns password hash!
|
||||
@router.get("/users/{user_id}")
|
||||
def get_user(user_id: UUID, db: Session = Depends(get_db)) -> User:
|
||||
return user_crud.get(db, id=user_id) # Returns ORM model with ALL fields!
|
||||
return user_repo.get(db, id=user_id) # Returns ORM model with ALL fields!
|
||||
```
|
||||
|
||||
```python
|
||||
# ✅ CORRECT - Use response schema
|
||||
@router.get("/users/{user_id}", response_model=UserResponse)
|
||||
def get_user(user_id: UUID, db: Session = Depends(get_db)):
|
||||
user = user_crud.get(db, id=user_id)
|
||||
user = user_repo.get(db, id=user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return user # Pydantic filters to only UserResponse fields
|
||||
@@ -506,8 +506,8 @@ def revoke_session(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
session = session_crud.get(db, id=session_id)
|
||||
session_crud.deactivate(db, session_id=session_id)
|
||||
session = session_repo.get(db, id=session_id)
|
||||
session_repo.deactivate(db, session_id=session_id)
|
||||
# BUG: User can revoke ANYONE'S session!
|
||||
return {"message": "Session revoked"}
|
||||
```
|
||||
@@ -520,7 +520,7 @@ def revoke_session(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
session = session_crud.get(db, id=session_id)
|
||||
session = session_repo.get(db, id=session_id)
|
||||
|
||||
if not session:
|
||||
raise NotFoundError("Session not found")
|
||||
@@ -529,7 +529,7 @@ def revoke_session(
|
||||
if session.user_id != current_user.id:
|
||||
raise AuthorizationError("You can only revoke your own sessions")
|
||||
|
||||
session_crud.deactivate(db, session_id=session_id)
|
||||
session_repo.deactivate(db, session_id=session_id)
|
||||
return {"message": "Session revoked"}
|
||||
```
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ backend/tests/
|
||||
│ └── test_database_workflows.py # PostgreSQL workflow tests
|
||||
│
|
||||
├── api/ # Integration tests (SQLite, fast)
|
||||
├── crud/ # Unit tests
|
||||
├── repositories/ # Repository unit tests
|
||||
└── conftest.py # Standard fixtures
|
||||
```
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/bin/sh
|
||||
set -e
|
||||
echo "Starting Backend"
|
||||
|
||||
|
||||
@@ -43,9 +43,8 @@ dependencies = [
|
||||
"pytz>=2024.1",
|
||||
"pillow>=12.1.1",
|
||||
"apscheduler==3.11.0",
|
||||
# Security and authentication (pinned for reproducibility)
|
||||
"python-jose==3.4.0",
|
||||
"passlib==1.7.4",
|
||||
# Security and authentication
|
||||
"PyJWT>=2.9.0",
|
||||
"bcrypt==4.2.1",
|
||||
"cryptography>=46.0.5",
|
||||
# OAuth authentication
|
||||
@@ -73,6 +72,9 @@ dev = [
|
||||
"pip-licenses>=4.0.0", # License compliance checking
|
||||
"detect-secrets>=1.5.0", # Hardcoded secrets detection
|
||||
|
||||
# Performance benchmarking
|
||||
"pytest-benchmark>=4.0.0", # Performance regression detection
|
||||
|
||||
# Pre-commit hooks
|
||||
"pre-commit>=4.0.0", # Git pre-commit hook framework
|
||||
]
|
||||
@@ -158,7 +160,7 @@ unfixable = []
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"app/alembic/env.py" = ["E402", "F403", "F405"] # Alembic requires specific import order
|
||||
"app/alembic/versions/*.py" = ["E402"] # Migration files have specific structure
|
||||
"tests/**/*.py" = ["S101", "N806", "B017", "N817", "S110", "ASYNC251", "RUF043", "T20"] # pytest: asserts, CamelCase fixtures, blind exceptions, try-pass patterns, async test helpers, and print for debugging are intentional
|
||||
"tests/**/*.py" = ["S101", "N806", "B017", "N817", "ASYNC251", "RUF043", "T20"] # pytest: asserts, CamelCase fixtures, blind exceptions, async test helpers, and print for debugging are intentional
|
||||
"app/models/__init__.py" = ["F401"] # __init__ files re-export modules
|
||||
"app/models/base.py" = ["F401"] # Re-exports Base for use by other models
|
||||
"app/utils/test_utils.py" = ["N806"] # SQLAlchemy session factories use CamelCase convention
|
||||
@@ -207,12 +209,15 @@ addopts = [
|
||||
"--cov=app",
|
||||
"--cov-report=term-missing",
|
||||
"--cov-report=html",
|
||||
"--ignore=tests/benchmarks", # benchmarks are incompatible with xdist; run via 'make benchmark'
|
||||
"-p", "no:benchmark", # disable pytest-benchmark plugin during normal runs (conflicts with xdist)
|
||||
]
|
||||
markers = [
|
||||
"sqlite: marks tests that should run on SQLite (mocked).",
|
||||
"postgres: marks tests that require a real PostgreSQL database.",
|
||||
"e2e: marks end-to-end tests requiring Docker containers.",
|
||||
"schemathesis: marks Schemathesis-generated API tests.",
|
||||
"benchmark: marks performance benchmark tests.",
|
||||
]
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.models.user import User
|
||||
from app.repositories.session import session_repo as session_crud
|
||||
from app.repositories.session import session_repo as session_repo
|
||||
|
||||
|
||||
class TestRevokedSessionSecurity:
|
||||
@@ -117,7 +117,7 @@ class TestRevokedSessionSecurity:
|
||||
|
||||
async with SessionLocal() as session:
|
||||
# Find and delete the session
|
||||
db_session = await session_crud.get_by_jti(session, jti=jti)
|
||||
db_session = await session_repo.get_by_jti(session, jti=jti)
|
||||
if db_session:
|
||||
await session.delete(db_session)
|
||||
await session.commit()
|
||||
|
||||
@@ -13,7 +13,7 @@ from httpx import AsyncClient
|
||||
|
||||
from app.models.organization import Organization
|
||||
from app.models.user import User
|
||||
from app.repositories.user import user_repo as user_crud
|
||||
from app.repositories.user import user_repo as user_repo
|
||||
|
||||
|
||||
class TestInactiveUserBlocking:
|
||||
@@ -50,7 +50,7 @@ class TestInactiveUserBlocking:
|
||||
|
||||
# Step 2: Admin deactivates the user
|
||||
async with SessionLocal() as session:
|
||||
user = await user_crud.get(session, id=async_test_user.id)
|
||||
user = await user_repo.get(session, id=async_test_user.id)
|
||||
user.is_active = False
|
||||
await session.commit()
|
||||
|
||||
@@ -80,7 +80,7 @@ class TestInactiveUserBlocking:
|
||||
|
||||
# Deactivate user
|
||||
async with SessionLocal() as session:
|
||||
user = await user_crud.get(session, id=async_test_user.id)
|
||||
user = await user_repo.get(session, id=async_test_user.id)
|
||||
user.is_active = False
|
||||
await session.commit()
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ async def async_test_user2(async_test_db):
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
from app.repositories.user import user_repo as user_crud
|
||||
from app.repositories.user import user_repo as user_repo
|
||||
from app.schemas.users import UserCreate
|
||||
|
||||
user_data = UserCreate(
|
||||
@@ -48,7 +48,7 @@ async def async_test_user2(async_test_db):
|
||||
first_name="Test",
|
||||
last_name="User2",
|
||||
)
|
||||
user = await user_crud.create(session, obj_in=user_data)
|
||||
user = await user_repo.create(session, obj_in=user_data)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
@@ -191,9 +191,9 @@ class TestRevokeSession:
|
||||
|
||||
# Verify session is deactivated
|
||||
async with SessionLocal() as session:
|
||||
from app.repositories.session import session_repo as session_crud
|
||||
from app.repositories.session import session_repo as session_repo
|
||||
|
||||
revoked_session = await session_crud.get(session, id=str(session_id))
|
||||
revoked_session = await session_repo.get(session, id=str(session_id))
|
||||
assert revoked_session.is_active is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -267,8 +267,8 @@ class TestCleanupExpiredSessions:
|
||||
"""Test successfully cleaning up expired sessions."""
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
# Create expired and active sessions using CRUD to avoid greenlet issues
|
||||
from app.repositories.session import session_repo as session_crud
|
||||
# Create expired and active sessions using repository to avoid greenlet issues
|
||||
from app.repositories.session import session_repo as session_repo
|
||||
from app.schemas.sessions import SessionCreate
|
||||
|
||||
async with SessionLocal() as db:
|
||||
@@ -282,7 +282,7 @@ class TestCleanupExpiredSessions:
|
||||
expires_at=datetime.now(UTC) - timedelta(days=1),
|
||||
last_used_at=datetime.now(UTC) - timedelta(days=2),
|
||||
)
|
||||
e1 = await session_crud.create_session(db, obj_in=e1_data)
|
||||
e1 = await session_repo.create_session(db, obj_in=e1_data)
|
||||
e1.is_active = False
|
||||
db.add(e1)
|
||||
|
||||
@@ -296,7 +296,7 @@ class TestCleanupExpiredSessions:
|
||||
expires_at=datetime.now(UTC) - timedelta(hours=1),
|
||||
last_used_at=datetime.now(UTC) - timedelta(hours=2),
|
||||
)
|
||||
e2 = await session_crud.create_session(db, obj_in=e2_data)
|
||||
e2 = await session_repo.create_session(db, obj_in=e2_data)
|
||||
e2.is_active = False
|
||||
db.add(e2)
|
||||
|
||||
@@ -310,7 +310,7 @@ class TestCleanupExpiredSessions:
|
||||
expires_at=datetime.now(UTC) + timedelta(days=7),
|
||||
last_used_at=datetime.now(UTC),
|
||||
)
|
||||
await session_crud.create_session(db, obj_in=a1_data)
|
||||
await session_repo.create_session(db, obj_in=a1_data)
|
||||
await db.commit()
|
||||
|
||||
# Cleanup expired sessions
|
||||
@@ -333,8 +333,8 @@ class TestCleanupExpiredSessions:
|
||||
"""Test cleanup when no sessions are expired."""
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
# Create only active sessions using CRUD
|
||||
from app.repositories.session import session_repo as session_crud
|
||||
# Create only active sessions using repository
|
||||
from app.repositories.session import session_repo as session_repo
|
||||
from app.schemas.sessions import SessionCreate
|
||||
|
||||
async with SessionLocal() as db:
|
||||
@@ -347,7 +347,7 @@ class TestCleanupExpiredSessions:
|
||||
expires_at=datetime.now(UTC) + timedelta(days=7),
|
||||
last_used_at=datetime.now(UTC),
|
||||
)
|
||||
await session_crud.create_session(db, obj_in=a1_data)
|
||||
await session_repo.create_session(db, obj_in=a1_data)
|
||||
await db.commit()
|
||||
|
||||
response = await client.delete(
|
||||
@@ -384,7 +384,7 @@ class TestSessionsAdditionalCases:
|
||||
|
||||
# Create multiple sessions
|
||||
async with SessionLocal() as session:
|
||||
from app.repositories.session import session_repo as session_crud
|
||||
from app.repositories.session import session_repo as session_repo
|
||||
from app.schemas.sessions import SessionCreate
|
||||
|
||||
for i in range(5):
|
||||
@@ -397,7 +397,7 @@ class TestSessionsAdditionalCases:
|
||||
expires_at=datetime.now(UTC) + timedelta(days=7),
|
||||
last_used_at=datetime.now(UTC),
|
||||
)
|
||||
await session_crud.create_session(session, obj_in=session_data)
|
||||
await session_repo.create_session(session, obj_in=session_data)
|
||||
await session.commit()
|
||||
|
||||
response = await client.get(
|
||||
@@ -431,7 +431,7 @@ class TestSessionsAdditionalCases:
|
||||
"""Test cleanup with mix of active/inactive and expired/not-expired sessions."""
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
from app.repositories.session import session_repo as session_crud
|
||||
from app.repositories.session import session_repo as session_repo
|
||||
from app.schemas.sessions import SessionCreate
|
||||
|
||||
async with SessionLocal() as db:
|
||||
@@ -445,7 +445,7 @@ class TestSessionsAdditionalCases:
|
||||
expires_at=datetime.now(UTC) - timedelta(days=1),
|
||||
last_used_at=datetime.now(UTC) - timedelta(days=2),
|
||||
)
|
||||
e1 = await session_crud.create_session(db, obj_in=e1_data)
|
||||
e1 = await session_repo.create_session(db, obj_in=e1_data)
|
||||
e1.is_active = False
|
||||
db.add(e1)
|
||||
|
||||
@@ -459,7 +459,7 @@ class TestSessionsAdditionalCases:
|
||||
expires_at=datetime.now(UTC) - timedelta(hours=1),
|
||||
last_used_at=datetime.now(UTC) - timedelta(hours=2),
|
||||
)
|
||||
await session_crud.create_session(db, obj_in=e2_data)
|
||||
await session_repo.create_session(db, obj_in=e2_data)
|
||||
|
||||
await db.commit()
|
||||
|
||||
@@ -530,7 +530,7 @@ class TestSessionExceptionHandlers:
|
||||
from app.repositories import session as session_module
|
||||
|
||||
# First create a session to revoke
|
||||
from app.repositories.session import session_repo as session_crud
|
||||
from app.repositories.session import session_repo as session_repo
|
||||
from app.schemas.sessions import SessionCreate
|
||||
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
@@ -545,7 +545,7 @@ class TestSessionExceptionHandlers:
|
||||
last_used_at=datetime.now(UTC),
|
||||
expires_at=datetime.now(UTC) + timedelta(days=60),
|
||||
)
|
||||
user_session = await session_crud.create_session(db, obj_in=session_in)
|
||||
user_session = await session_repo.create_session(db, obj_in=session_in)
|
||||
session_id = user_session.id
|
||||
|
||||
# Mock the deactivate method to raise an exception
|
||||
|
||||
@@ -157,7 +157,7 @@ class TestListUsers:
|
||||
response = await client.get("/api/v1/users")
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
# Note: Removed test_list_users_unexpected_error because mocking at CRUD level
|
||||
# Note: Removed test_list_users_unexpected_error because mocking at repository level
|
||||
# causes the exception to be raised before FastAPI can handle it properly
|
||||
|
||||
|
||||
|
||||
0
backend/tests/benchmarks/__init__.py
Normal file
0
backend/tests/benchmarks/__init__.py
Normal file
327
backend/tests/benchmarks/test_endpoint_performance.py
Normal file
327
backend/tests/benchmarks/test_endpoint_performance.py
Normal file
@@ -0,0 +1,327 @@
|
||||
"""
|
||||
Performance Benchmark Tests.
|
||||
|
||||
These tests establish baseline performance metrics for critical API endpoints
|
||||
and core operations, detecting regressions when response times degrade.
|
||||
|
||||
Usage:
|
||||
make benchmark # Run benchmarks and save baseline
|
||||
make benchmark-check # Run benchmarks and compare against saved baseline
|
||||
|
||||
Baselines are stored in .benchmarks/ and should be committed to version control
|
||||
so CI can detect performance regressions across commits.
|
||||
"""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.auth import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
get_password_hash,
|
||||
verify_password,
|
||||
)
|
||||
from app.main import app
|
||||
|
||||
pytestmark = [pytest.mark.benchmark]
|
||||
|
||||
# Pre-computed hash for sync benchmarks (avoids hashing in every iteration)
|
||||
_BENCH_PASSWORD = "BenchPass123!"
|
||||
_BENCH_HASH = get_password_hash(_BENCH_PASSWORD)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sync_client():
|
||||
"""Create a FastAPI test client with mocked database for stateless endpoints."""
|
||||
with patch("app.main.check_database_health") as mock_health_check:
|
||||
mock_health_check.return_value = True
|
||||
yield TestClient(app)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Stateless Endpoint Benchmarks (no DB required)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_health_endpoint_performance(sync_client, benchmark):
|
||||
"""Benchmark: GET /health should respond within acceptable latency."""
|
||||
result = benchmark(sync_client.get, "/health")
|
||||
assert result.status_code == 200
|
||||
|
||||
|
||||
def test_openapi_schema_performance(sync_client, benchmark):
|
||||
"""Benchmark: OpenAPI schema generation should not regress."""
|
||||
result = benchmark(sync_client.get, "/api/v1/openapi.json")
|
||||
assert result.status_code == 200
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Core Crypto & Token Benchmarks (no DB required)
|
||||
#
|
||||
# These benchmark the CPU-intensive operations that underpin auth:
|
||||
# password hashing, verification, and JWT creation/decoding.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_password_hashing_performance(benchmark):
|
||||
"""Benchmark: bcrypt password hashing (CPU-bound, ~100ms expected)."""
|
||||
result = benchmark(get_password_hash, _BENCH_PASSWORD)
|
||||
assert result.startswith("$2b$")
|
||||
|
||||
|
||||
def test_password_verification_performance(benchmark):
|
||||
"""Benchmark: bcrypt password verification against a known hash."""
|
||||
result = benchmark(verify_password, _BENCH_PASSWORD, _BENCH_HASH)
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_access_token_creation_performance(benchmark):
|
||||
"""Benchmark: JWT access token generation."""
|
||||
user_id = str(uuid.uuid4())
|
||||
token = benchmark(create_access_token, user_id)
|
||||
assert isinstance(token, str)
|
||||
assert len(token) > 0
|
||||
|
||||
|
||||
def test_refresh_token_creation_performance(benchmark):
|
||||
"""Benchmark: JWT refresh token generation."""
|
||||
user_id = str(uuid.uuid4())
|
||||
token = benchmark(create_refresh_token, user_id)
|
||||
assert isinstance(token, str)
|
||||
assert len(token) > 0
|
||||
|
||||
|
||||
def test_token_decode_performance(benchmark):
|
||||
"""Benchmark: JWT token decoding and validation."""
|
||||
user_id = str(uuid.uuid4())
|
||||
token = create_access_token(user_id)
|
||||
payload = benchmark(decode_token, token, "access")
|
||||
assert payload.sub == user_id
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Database-dependent Endpoint Benchmarks (async, manual timing)
|
||||
#
|
||||
# pytest-benchmark does not support async functions natively. These tests
|
||||
# measure latency manually and assert against a maximum threshold (in ms)
|
||||
# to catch performance regressions.
|
||||
# =============================================================================
|
||||
|
||||
MAX_LOGIN_MS = 500
|
||||
MAX_GET_USER_MS = 200
|
||||
MAX_REGISTER_MS = 500
|
||||
MAX_TOKEN_REFRESH_MS = 200
|
||||
MAX_SESSIONS_LIST_MS = 200
|
||||
MAX_USER_UPDATE_MS = 200
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def bench_user(async_test_db):
|
||||
"""Create a test user for benchmark tests."""
|
||||
from app.models.user import User
|
||||
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="bench@example.com",
|
||||
password_hash=get_password_hash("BenchPass123!"),
|
||||
first_name="Bench",
|
||||
last_name="User",
|
||||
is_active=True,
|
||||
is_superuser=False,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def bench_token(client, bench_user):
|
||||
"""Get an auth token for the benchmark user."""
|
||||
response = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "bench@example.com", "password": "BenchPass123!"},
|
||||
)
|
||||
assert response.status_code == 200, f"Login failed: {response.text}"
|
||||
return response.json()["access_token"]
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def bench_refresh_token(client, bench_user):
|
||||
"""Get a refresh token for the benchmark user."""
|
||||
response = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "bench@example.com", "password": "BenchPass123!"},
|
||||
)
|
||||
assert response.status_code == 200, f"Login failed: {response.text}"
|
||||
return response.json()["refresh_token"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_latency(client, bench_user):
|
||||
"""Performance: POST /api/v1/auth/login must respond under threshold."""
|
||||
iterations = 5
|
||||
total_ms = 0.0
|
||||
|
||||
for _ in range(iterations):
|
||||
start = time.perf_counter()
|
||||
response = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "bench@example.com", "password": "BenchPass123!"},
|
||||
)
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
total_ms += elapsed_ms
|
||||
assert response.status_code == 200
|
||||
|
||||
mean_ms = total_ms / iterations
|
||||
print(f"\n Login mean latency: {mean_ms:.1f}ms (threshold: {MAX_LOGIN_MS}ms)")
|
||||
assert mean_ms < MAX_LOGIN_MS, (
|
||||
f"Login latency regression: {mean_ms:.1f}ms exceeds {MAX_LOGIN_MS}ms threshold"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_user_latency(client, bench_token):
|
||||
"""Performance: GET /api/v1/users/me must respond under threshold."""
|
||||
iterations = 10
|
||||
total_ms = 0.0
|
||||
|
||||
for _ in range(iterations):
|
||||
start = time.perf_counter()
|
||||
response = await client.get(
|
||||
"/api/v1/users/me",
|
||||
headers={"Authorization": f"Bearer {bench_token}"},
|
||||
)
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
total_ms += elapsed_ms
|
||||
assert response.status_code == 200
|
||||
|
||||
mean_ms = total_ms / iterations
|
||||
print(
|
||||
f"\n Get user mean latency: {mean_ms:.1f}ms (threshold: {MAX_GET_USER_MS}ms)"
|
||||
)
|
||||
assert mean_ms < MAX_GET_USER_MS, (
|
||||
f"Get user latency regression: {mean_ms:.1f}ms exceeds {MAX_GET_USER_MS}ms threshold"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_latency(client):
|
||||
"""Performance: POST /api/v1/auth/register must respond under threshold."""
|
||||
iterations = 3
|
||||
total_ms = 0.0
|
||||
|
||||
for i in range(iterations):
|
||||
start = time.perf_counter()
|
||||
response = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": f"benchreg{i}@example.com",
|
||||
"password": "BenchRegPass123!",
|
||||
"first_name": "Bench",
|
||||
"last_name": "Register",
|
||||
},
|
||||
)
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
total_ms += elapsed_ms
|
||||
assert response.status_code == 201, f"Register failed: {response.text}"
|
||||
|
||||
mean_ms = total_ms / iterations
|
||||
print(
|
||||
f"\n Register mean latency: {mean_ms:.1f}ms (threshold: {MAX_REGISTER_MS}ms)"
|
||||
)
|
||||
assert mean_ms < MAX_REGISTER_MS, (
|
||||
f"Register latency regression: {mean_ms:.1f}ms exceeds {MAX_REGISTER_MS}ms threshold"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_refresh_latency(client, bench_refresh_token):
|
||||
"""Performance: POST /api/v1/auth/refresh must respond under threshold."""
|
||||
iterations = 5
|
||||
total_ms = 0.0
|
||||
|
||||
for _ in range(iterations):
|
||||
start = time.perf_counter()
|
||||
response = await client.post(
|
||||
"/api/v1/auth/refresh",
|
||||
json={"refresh_token": bench_refresh_token},
|
||||
)
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
total_ms += elapsed_ms
|
||||
assert response.status_code == 200, f"Refresh failed: {response.text}"
|
||||
# Use the new refresh token for the next iteration
|
||||
bench_refresh_token = response.json()["refresh_token"]
|
||||
|
||||
mean_ms = total_ms / iterations
|
||||
print(
|
||||
f"\n Token refresh mean latency: {mean_ms:.1f}ms (threshold: {MAX_TOKEN_REFRESH_MS}ms)"
|
||||
)
|
||||
assert mean_ms < MAX_TOKEN_REFRESH_MS, (
|
||||
f"Token refresh latency regression: {mean_ms:.1f}ms exceeds {MAX_TOKEN_REFRESH_MS}ms threshold"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sessions_list_latency(client, bench_token):
|
||||
"""Performance: GET /api/v1/sessions must respond under threshold."""
|
||||
iterations = 10
|
||||
total_ms = 0.0
|
||||
|
||||
for _ in range(iterations):
|
||||
start = time.perf_counter()
|
||||
response = await client.get(
|
||||
"/api/v1/sessions/me",
|
||||
headers={"Authorization": f"Bearer {bench_token}"},
|
||||
)
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
total_ms += elapsed_ms
|
||||
assert response.status_code == 200
|
||||
|
||||
mean_ms = total_ms / iterations
|
||||
print(
|
||||
f"\n Sessions list mean latency: {mean_ms:.1f}ms (threshold: {MAX_SESSIONS_LIST_MS}ms)"
|
||||
)
|
||||
assert mean_ms < MAX_SESSIONS_LIST_MS, (
|
||||
f"Sessions list latency regression: {mean_ms:.1f}ms exceeds {MAX_SESSIONS_LIST_MS}ms threshold"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_profile_update_latency(client, bench_token):
|
||||
"""Performance: PATCH /api/v1/users/me must respond under threshold."""
|
||||
iterations = 5
|
||||
total_ms = 0.0
|
||||
|
||||
for i in range(iterations):
|
||||
start = time.perf_counter()
|
||||
response = await client.patch(
|
||||
"/api/v1/users/me",
|
||||
headers={"Authorization": f"Bearer {bench_token}"},
|
||||
json={"first_name": f"Bench{i}"},
|
||||
)
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
total_ms += elapsed_ms
|
||||
assert response.status_code == 200, f"Update failed: {response.text}"
|
||||
|
||||
mean_ms = total_ms / iterations
|
||||
print(
|
||||
f"\n User update mean latency: {mean_ms:.1f}ms (threshold: {MAX_USER_UPDATE_MS}ms)"
|
||||
)
|
||||
assert mean_ms < MAX_USER_UPDATE_MS, (
|
||||
f"User update latency regression: {mean_ms:.1f}ms exceeds {MAX_USER_UPDATE_MS}ms threshold"
|
||||
)
|
||||
@@ -2,8 +2,8 @@
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from jose import jwt
|
||||
|
||||
from app.core.auth import (
|
||||
TokenExpiredError,
|
||||
@@ -215,6 +215,7 @@ class TestTokenDecoding:
|
||||
payload = {
|
||||
"sub": 123, # sub should be a string, not an integer
|
||||
"exp": int((now + timedelta(minutes=30)).timestamp()),
|
||||
"iat": int(now.timestamp()),
|
||||
}
|
||||
|
||||
token = jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
@@ -9,8 +9,8 @@ Critical security tests covering:
|
||||
These tests cover critical security vulnerabilities that could be exploited.
|
||||
"""
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from jose import jwt
|
||||
|
||||
from app.core.auth import TokenInvalidError, create_access_token, decode_token
|
||||
from app.core.config import settings
|
||||
@@ -38,8 +38,8 @@ class TestJWTAlgorithmSecurityAttacks:
|
||||
Attacker creates a token with "alg: none" to bypass signature verification.
|
||||
|
||||
NOTE: Lines 209 and 212 in auth.py are DEFENSIVE CODE that's never reached
|
||||
because python-jose library rejects "none" algorithm tokens BEFORE we get there.
|
||||
This is good for security! The library throws JWTError which becomes TokenInvalidError.
|
||||
because PyJWT rejects "none" algorithm tokens BEFORE we get there.
|
||||
This is good for security! The library throws InvalidTokenError which becomes TokenInvalidError.
|
||||
|
||||
This test verifies the overall protection works, even though our defensive
|
||||
checks at lines 209-212 don't execute because the library catches it first.
|
||||
@@ -108,36 +108,33 @@ class TestJWTAlgorithmSecurityAttacks:
|
||||
Test that tokens with wrong algorithm are rejected.
|
||||
|
||||
Attack Scenario:
|
||||
Attacker changes algorithm from HS256 to RS256, attempting to use
|
||||
the public key as the HMAC secret. This could allow token forgery.
|
||||
Attacker changes the "alg" header to RS256 while keeping an HMAC
|
||||
signature, attempting algorithm confusion to forge tokens.
|
||||
|
||||
Reference: https://www.nccgroup.com/us/about-us/newsroom-and-events/blog/2019/january/jwt-algorithm-confusion/
|
||||
|
||||
NOTE: Like the "none" algorithm test, python-jose library catches this
|
||||
before our defensive checks at line 212. This is good for security!
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
|
||||
now = int(time.time())
|
||||
|
||||
# Create a valid payload
|
||||
payload = {"sub": "user123", "exp": now + 3600, "iat": now, "type": "access"}
|
||||
|
||||
# Encode with wrong algorithm (RS256 instead of HS256)
|
||||
# This simulates an attacker trying algorithm substitution
|
||||
wrong_algorithm = "RS256" if settings.ALGORITHM == "HS256" else "HS256"
|
||||
|
||||
try:
|
||||
malicious_token = jwt.encode(
|
||||
payload, settings.SECRET_KEY, algorithm=wrong_algorithm
|
||||
# Hand-craft a token claiming RS256 in the header — PyJWT cannot encode
|
||||
# RS256 with an HMAC key, so we craft the header manually (same technique
|
||||
# as the "alg: none" tests) to produce a token that actually reaches decode_token.
|
||||
header = {"alg": "RS256", "typ": "JWT"}
|
||||
header_encoded = (
|
||||
base64.urlsafe_b64encode(json.dumps(header).encode()).decode().rstrip("=")
|
||||
)
|
||||
payload_encoded = (
|
||||
base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
|
||||
)
|
||||
# Attach a fake signature to form a complete (but invalid) JWT
|
||||
malicious_token = f"{header_encoded}.{payload_encoded}.fakesignature"
|
||||
|
||||
# Should reject the token (library catches mismatch)
|
||||
with pytest.raises(TokenInvalidError):
|
||||
decode_token(malicious_token)
|
||||
except Exception:
|
||||
# If encoding fails, that's also acceptable (library protection)
|
||||
pass
|
||||
|
||||
def test_reject_hs384_when_hs256_expected(self):
|
||||
"""
|
||||
@@ -151,17 +148,11 @@ class TestJWTAlgorithmSecurityAttacks:
|
||||
|
||||
payload = {"sub": "user123", "exp": now + 3600, "iat": now, "type": "access"}
|
||||
|
||||
# Create token with HS384 instead of HS256
|
||||
try:
|
||||
malicious_token = jwt.encode(
|
||||
payload, settings.SECRET_KEY, algorithm="HS384"
|
||||
)
|
||||
# Create token with HS384 instead of HS256 (HMAC key works with HS384)
|
||||
malicious_token = jwt.encode(payload, settings.SECRET_KEY, algorithm="HS384")
|
||||
|
||||
with pytest.raises(TokenInvalidError):
|
||||
decode_token(malicious_token)
|
||||
except Exception:
|
||||
# If encoding fails, that's also fine
|
||||
pass
|
||||
|
||||
def test_valid_token_with_correct_algorithm_accepted(self):
|
||||
"""
|
||||
|
||||
@@ -46,7 +46,7 @@ async def login_user(client, email: str, password: str = "SecurePassword123!"):
|
||||
|
||||
async def create_superuser(e2e_db_session, email: str, password: str):
|
||||
"""Create a superuser directly in the database."""
|
||||
from app.repositories.user import user_repo as user_crud
|
||||
from app.repositories.user import user_repo as user_repo
|
||||
from app.schemas.users import UserCreate
|
||||
|
||||
user_in = UserCreate(
|
||||
@@ -56,7 +56,7 @@ async def create_superuser(e2e_db_session, email: str, password: str):
|
||||
last_name="User",
|
||||
is_superuser=True,
|
||||
)
|
||||
user = await user_crud.create(e2e_db_session, obj_in=user_in)
|
||||
user = await user_repo.create(e2e_db_session, obj_in=user_in)
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -27,13 +27,16 @@ except ImportError:
|
||||
pytestmark = [
|
||||
pytest.mark.e2e,
|
||||
pytest.mark.schemathesis,
|
||||
pytest.mark.skipif(
|
||||
not SCHEMATHESIS_AVAILABLE,
|
||||
reason="schemathesis not installed - run: make install-e2e",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
if not SCHEMATHESIS_AVAILABLE:
|
||||
|
||||
def test_schemathesis_compatibility():
|
||||
"""Gracefully handle missing schemathesis dependency."""
|
||||
pytest.skip("schemathesis not installed - run: make install-e2e")
|
||||
|
||||
|
||||
if SCHEMATHESIS_AVAILABLE:
|
||||
from app.main import app
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ async def register_and_login(client, email: str, password: str = "SecurePassword
|
||||
|
||||
async def create_superuser_and_login(client, db_session):
|
||||
"""Helper to create a superuser directly in DB and login."""
|
||||
from app.repositories.user import user_repo as user_crud
|
||||
from app.repositories.user import user_repo as user_repo
|
||||
from app.schemas.users import UserCreate
|
||||
|
||||
email = f"admin-{uuid4().hex[:8]}@example.com"
|
||||
@@ -60,7 +60,7 @@ async def create_superuser_and_login(client, db_session):
|
||||
last_name="User",
|
||||
is_superuser=True,
|
||||
)
|
||||
await user_crud.create(db_session, obj_in=user_in)
|
||||
await user_repo.create(db_session, obj_in=user_in)
|
||||
|
||||
# Login
|
||||
login_resp = await client.post(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# tests/crud/test_base.py
|
||||
# tests/repositories/test_base.py
|
||||
"""
|
||||
Comprehensive tests for CRUDBase class covering all error paths and edge cases.
|
||||
Comprehensive tests for BaseRepository class covering all error paths and edge cases.
|
||||
"""
|
||||
|
||||
from datetime import UTC
|
||||
@@ -16,11 +16,11 @@ from app.core.repository_exceptions import (
|
||||
IntegrityConstraintError,
|
||||
InvalidInputError,
|
||||
)
|
||||
from app.repositories.user import user_repo as user_crud
|
||||
from app.repositories.user import user_repo as user_repo
|
||||
from app.schemas.users import UserCreate, UserUpdate
|
||||
|
||||
|
||||
class TestCRUDBaseGet:
|
||||
class TestRepositoryBaseGet:
|
||||
"""Tests for get method covering UUID validation and options."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -29,7 +29,7 @@ class TestCRUDBaseGet:
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
result = await user_crud.get(session, id="invalid-uuid")
|
||||
result = await user_repo.get(session, id="invalid-uuid")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -38,7 +38,7 @@ class TestCRUDBaseGet:
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
result = await user_crud.get(session, id=12345) # int instead of UUID
|
||||
result = await user_repo.get(session, id=12345) # int instead of UUID
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -48,7 +48,7 @@ class TestCRUDBaseGet:
|
||||
|
||||
async with SessionLocal() as session:
|
||||
# Pass UUID object directly
|
||||
result = await user_crud.get(session, id=async_test_user.id)
|
||||
result = await user_repo.get(session, id=async_test_user.id)
|
||||
assert result is not None
|
||||
assert result.id == async_test_user.id
|
||||
|
||||
@@ -60,7 +60,7 @@ class TestCRUDBaseGet:
|
||||
async with SessionLocal() as session:
|
||||
# Test that options parameter is accepted and doesn't error
|
||||
# We pass an empty list which still tests the code path
|
||||
result = await user_crud.get(
|
||||
result = await user_repo.get(
|
||||
session, id=str(async_test_user.id), options=[]
|
||||
)
|
||||
assert result is not None
|
||||
@@ -74,10 +74,10 @@ class TestCRUDBaseGet:
|
||||
# Mock execute to raise an exception
|
||||
with patch.object(session, "execute", side_effect=Exception("DB error")):
|
||||
with pytest.raises(Exception, match="DB error"):
|
||||
await user_crud.get(session, id=str(uuid4()))
|
||||
await user_repo.get(session, id=str(uuid4()))
|
||||
|
||||
|
||||
class TestCRUDBaseGetMulti:
|
||||
class TestRepositoryBaseGetMulti:
|
||||
"""Tests for get_multi method covering pagination validation and options."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -87,7 +87,7 @@ class TestCRUDBaseGetMulti:
|
||||
|
||||
async with SessionLocal() as session:
|
||||
with pytest.raises(InvalidInputError, match="skip must be non-negative"):
|
||||
await user_crud.get_multi(session, skip=-1)
|
||||
await user_repo.get_multi(session, skip=-1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multi_negative_limit(self, async_test_db):
|
||||
@@ -96,7 +96,7 @@ class TestCRUDBaseGetMulti:
|
||||
|
||||
async with SessionLocal() as session:
|
||||
with pytest.raises(InvalidInputError, match="limit must be non-negative"):
|
||||
await user_crud.get_multi(session, limit=-1)
|
||||
await user_repo.get_multi(session, limit=-1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multi_limit_too_large(self, async_test_db):
|
||||
@@ -105,7 +105,7 @@ class TestCRUDBaseGetMulti:
|
||||
|
||||
async with SessionLocal() as session:
|
||||
with pytest.raises(InvalidInputError, match="Maximum limit is 1000"):
|
||||
await user_crud.get_multi(session, limit=1001)
|
||||
await user_repo.get_multi(session, limit=1001)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multi_with_options(self, async_test_db, async_test_user):
|
||||
@@ -114,7 +114,7 @@ class TestCRUDBaseGetMulti:
|
||||
|
||||
async with SessionLocal() as session:
|
||||
# Test that options parameter is accepted
|
||||
results = await user_crud.get_multi(session, skip=0, limit=10, options=[])
|
||||
results = await user_repo.get_multi(session, skip=0, limit=10, options=[])
|
||||
assert isinstance(results, list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -125,10 +125,10 @@ class TestCRUDBaseGetMulti:
|
||||
async with SessionLocal() as session:
|
||||
with patch.object(session, "execute", side_effect=Exception("DB error")):
|
||||
with pytest.raises(Exception, match="DB error"):
|
||||
await user_crud.get_multi(session)
|
||||
await user_repo.get_multi(session)
|
||||
|
||||
|
||||
class TestCRUDBaseCreate:
|
||||
class TestRepositoryBaseCreate:
|
||||
"""Tests for create method covering various error conditions."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -146,7 +146,7 @@ class TestCRUDBaseCreate:
|
||||
)
|
||||
|
||||
with pytest.raises(DuplicateEntryError, match="already exists"):
|
||||
await user_crud.create(session, obj_in=user_data)
|
||||
await user_repo.create(session, obj_in=user_data)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_integrity_error_non_duplicate(self, async_test_db):
|
||||
@@ -173,11 +173,11 @@ class TestCRUDBaseCreate:
|
||||
with pytest.raises(
|
||||
DuplicateEntryError, match="Database integrity error"
|
||||
):
|
||||
await user_crud.create(session, obj_in=user_data)
|
||||
await user_repo.create(session, obj_in=user_data)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_operational_error(self, async_test_db):
|
||||
"""Test create with OperationalError (user CRUD catches as generic Exception)."""
|
||||
"""Test create with OperationalError (user repository catches as generic Exception)."""
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
@@ -195,13 +195,13 @@ class TestCRUDBaseCreate:
|
||||
last_name="User",
|
||||
)
|
||||
|
||||
# User CRUD catches this as generic Exception and re-raises
|
||||
# User repository catches this as generic Exception and re-raises
|
||||
with pytest.raises(OperationalError):
|
||||
await user_crud.create(session, obj_in=user_data)
|
||||
await user_repo.create(session, obj_in=user_data)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_data_error(self, async_test_db):
|
||||
"""Test create with DataError (user CRUD catches as generic Exception)."""
|
||||
"""Test create with DataError (user repository catches as generic Exception)."""
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
@@ -217,9 +217,9 @@ class TestCRUDBaseCreate:
|
||||
last_name="User",
|
||||
)
|
||||
|
||||
# User CRUD catches this as generic Exception and re-raises
|
||||
# User repository catches this as generic Exception and re-raises
|
||||
with pytest.raises(DataError):
|
||||
await user_crud.create(session, obj_in=user_data)
|
||||
await user_repo.create(session, obj_in=user_data)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_unexpected_error(self, async_test_db):
|
||||
@@ -238,10 +238,10 @@ class TestCRUDBaseCreate:
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Unexpected error"):
|
||||
await user_crud.create(session, obj_in=user_data)
|
||||
await user_repo.create(session, obj_in=user_data)
|
||||
|
||||
|
||||
class TestCRUDBaseUpdate:
|
||||
class TestRepositoryBaseUpdate:
|
||||
"""Tests for update method covering error conditions."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -251,7 +251,7 @@ class TestCRUDBaseUpdate:
|
||||
|
||||
# Create another user
|
||||
async with SessionLocal() as session:
|
||||
from app.repositories.user import user_repo as user_crud
|
||||
from app.repositories.user import user_repo as user_repo
|
||||
|
||||
user2_data = UserCreate(
|
||||
email="user2@example.com",
|
||||
@@ -259,12 +259,12 @@ class TestCRUDBaseUpdate:
|
||||
first_name="User",
|
||||
last_name="Two",
|
||||
)
|
||||
user2 = await user_crud.create(session, obj_in=user2_data)
|
||||
user2 = await user_repo.create(session, obj_in=user2_data)
|
||||
await session.commit()
|
||||
|
||||
# Try to update user2 with user1's email
|
||||
async with SessionLocal() as session:
|
||||
user2_obj = await user_crud.get(session, id=str(user2.id))
|
||||
user2_obj = await user_repo.get(session, id=str(user2.id))
|
||||
|
||||
with patch.object(
|
||||
session,
|
||||
@@ -276,7 +276,7 @@ class TestCRUDBaseUpdate:
|
||||
update_data = UserUpdate(email=async_test_user.email)
|
||||
|
||||
with pytest.raises(DuplicateEntryError, match="already exists"):
|
||||
await user_crud.update(
|
||||
await user_repo.update(
|
||||
session, db_obj=user2_obj, obj_in=update_data
|
||||
)
|
||||
|
||||
@@ -286,10 +286,10 @@ class TestCRUDBaseUpdate:
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
user = await user_crud.get(session, id=str(async_test_user.id))
|
||||
user = await user_repo.get(session, id=str(async_test_user.id))
|
||||
|
||||
# Update with dict (tests lines 164-165)
|
||||
updated = await user_crud.update(
|
||||
updated = await user_repo.update(
|
||||
session, db_obj=user, obj_in={"first_name": "UpdatedName"}
|
||||
)
|
||||
assert updated.first_name == "UpdatedName"
|
||||
@@ -300,7 +300,7 @@ class TestCRUDBaseUpdate:
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
user = await user_crud.get(session, id=str(async_test_user.id))
|
||||
user = await user_repo.get(session, id=str(async_test_user.id))
|
||||
|
||||
with patch.object(
|
||||
session,
|
||||
@@ -312,7 +312,7 @@ class TestCRUDBaseUpdate:
|
||||
with pytest.raises(
|
||||
IntegrityConstraintError, match="Database integrity error"
|
||||
):
|
||||
await user_crud.update(
|
||||
await user_repo.update(
|
||||
session, db_obj=user, obj_in={"first_name": "Test"}
|
||||
)
|
||||
|
||||
@@ -322,7 +322,7 @@ class TestCRUDBaseUpdate:
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
user = await user_crud.get(session, id=str(async_test_user.id))
|
||||
user = await user_repo.get(session, id=str(async_test_user.id))
|
||||
|
||||
with patch.object(
|
||||
session,
|
||||
@@ -334,7 +334,7 @@ class TestCRUDBaseUpdate:
|
||||
with pytest.raises(
|
||||
IntegrityConstraintError, match="Database operation failed"
|
||||
):
|
||||
await user_crud.update(
|
||||
await user_repo.update(
|
||||
session, db_obj=user, obj_in={"first_name": "Test"}
|
||||
)
|
||||
|
||||
@@ -344,18 +344,18 @@ class TestCRUDBaseUpdate:
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
user = await user_crud.get(session, id=str(async_test_user.id))
|
||||
user = await user_repo.get(session, id=str(async_test_user.id))
|
||||
|
||||
with patch.object(
|
||||
session, "commit", side_effect=RuntimeError("Unexpected")
|
||||
):
|
||||
with pytest.raises(RuntimeError):
|
||||
await user_crud.update(
|
||||
await user_repo.update(
|
||||
session, db_obj=user, obj_in={"first_name": "Test"}
|
||||
)
|
||||
|
||||
|
||||
class TestCRUDBaseRemove:
|
||||
class TestRepositoryBaseRemove:
|
||||
"""Tests for remove method covering UUID validation and error conditions."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -364,7 +364,7 @@ class TestCRUDBaseRemove:
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
result = await user_crud.remove(session, id="invalid-uuid")
|
||||
result = await user_repo.remove(session, id="invalid-uuid")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -380,13 +380,13 @@ class TestCRUDBaseRemove:
|
||||
first_name="To",
|
||||
last_name="Delete",
|
||||
)
|
||||
user = await user_crud.create(session, obj_in=user_data)
|
||||
user = await user_repo.create(session, obj_in=user_data)
|
||||
user_id = user.id
|
||||
await session.commit()
|
||||
|
||||
# Delete with UUID object
|
||||
async with SessionLocal() as session:
|
||||
result = await user_crud.remove(session, id=user_id) # UUID object
|
||||
result = await user_repo.remove(session, id=user_id) # UUID object
|
||||
assert result is not None
|
||||
assert result.id == user_id
|
||||
|
||||
@@ -396,7 +396,7 @@ class TestCRUDBaseRemove:
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
result = await user_crud.remove(session, id=str(uuid4()))
|
||||
result = await user_repo.remove(session, id=str(uuid4()))
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -417,7 +417,7 @@ class TestCRUDBaseRemove:
|
||||
IntegrityConstraintError,
|
||||
match="Cannot delete.*referenced by other records",
|
||||
):
|
||||
await user_crud.remove(session, id=str(async_test_user.id))
|
||||
await user_repo.remove(session, id=str(async_test_user.id))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_unexpected_error(self, async_test_db, async_test_user):
|
||||
@@ -429,10 +429,10 @@ class TestCRUDBaseRemove:
|
||||
session, "commit", side_effect=RuntimeError("Unexpected")
|
||||
):
|
||||
with pytest.raises(RuntimeError):
|
||||
await user_crud.remove(session, id=str(async_test_user.id))
|
||||
await user_repo.remove(session, id=str(async_test_user.id))
|
||||
|
||||
|
||||
class TestCRUDBaseGetMultiWithTotal:
|
||||
class TestRepositoryBaseGetMultiWithTotal:
|
||||
"""Tests for get_multi_with_total method covering pagination, filtering, sorting."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -441,7 +441,7 @@ class TestCRUDBaseGetMultiWithTotal:
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
items, total = await user_crud.get_multi_with_total(
|
||||
items, total = await user_repo.get_multi_with_total(
|
||||
session, skip=0, limit=10
|
||||
)
|
||||
assert isinstance(items, list)
|
||||
@@ -455,7 +455,7 @@ class TestCRUDBaseGetMultiWithTotal:
|
||||
|
||||
async with SessionLocal() as session:
|
||||
with pytest.raises(InvalidInputError, match="skip must be non-negative"):
|
||||
await user_crud.get_multi_with_total(session, skip=-1)
|
||||
await user_repo.get_multi_with_total(session, skip=-1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multi_with_total_negative_limit(self, async_test_db):
|
||||
@@ -464,7 +464,7 @@ class TestCRUDBaseGetMultiWithTotal:
|
||||
|
||||
async with SessionLocal() as session:
|
||||
with pytest.raises(InvalidInputError, match="limit must be non-negative"):
|
||||
await user_crud.get_multi_with_total(session, limit=-1)
|
||||
await user_repo.get_multi_with_total(session, limit=-1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multi_with_total_limit_too_large(self, async_test_db):
|
||||
@@ -473,7 +473,7 @@ class TestCRUDBaseGetMultiWithTotal:
|
||||
|
||||
async with SessionLocal() as session:
|
||||
with pytest.raises(InvalidInputError, match="Maximum limit is 1000"):
|
||||
await user_crud.get_multi_with_total(session, limit=1001)
|
||||
await user_repo.get_multi_with_total(session, limit=1001)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multi_with_total_with_filters(
|
||||
@@ -484,7 +484,7 @@ class TestCRUDBaseGetMultiWithTotal:
|
||||
|
||||
async with SessionLocal() as session:
|
||||
filters = {"email": async_test_user.email}
|
||||
items, total = await user_crud.get_multi_with_total(
|
||||
items, total = await user_repo.get_multi_with_total(
|
||||
session, filters=filters
|
||||
)
|
||||
assert total == 1
|
||||
@@ -512,12 +512,12 @@ class TestCRUDBaseGetMultiWithTotal:
|
||||
first_name="ZZZ",
|
||||
last_name="User",
|
||||
)
|
||||
await user_crud.create(session, obj_in=user_data1)
|
||||
await user_crud.create(session, obj_in=user_data2)
|
||||
await user_repo.create(session, obj_in=user_data1)
|
||||
await user_repo.create(session, obj_in=user_data2)
|
||||
await session.commit()
|
||||
|
||||
async with SessionLocal() as session:
|
||||
items, total = await user_crud.get_multi_with_total(
|
||||
items, total = await user_repo.get_multi_with_total(
|
||||
session, sort_by="email", sort_order="asc"
|
||||
)
|
||||
assert total >= 3
|
||||
@@ -545,12 +545,12 @@ class TestCRUDBaseGetMultiWithTotal:
|
||||
first_name="CCC",
|
||||
last_name="User",
|
||||
)
|
||||
await user_crud.create(session, obj_in=user_data1)
|
||||
await user_crud.create(session, obj_in=user_data2)
|
||||
await user_repo.create(session, obj_in=user_data1)
|
||||
await user_repo.create(session, obj_in=user_data2)
|
||||
await session.commit()
|
||||
|
||||
async with SessionLocal() as session:
|
||||
items, _total = await user_crud.get_multi_with_total(
|
||||
items, _total = await user_repo.get_multi_with_total(
|
||||
session, sort_by="email", sort_order="desc", limit=1
|
||||
)
|
||||
assert len(items) == 1
|
||||
@@ -570,19 +570,19 @@ class TestCRUDBaseGetMultiWithTotal:
|
||||
first_name=f"User{i}",
|
||||
last_name="Test",
|
||||
)
|
||||
await user_crud.create(session, obj_in=user_data)
|
||||
await user_repo.create(session, obj_in=user_data)
|
||||
await session.commit()
|
||||
|
||||
async with SessionLocal() as session:
|
||||
# Get first page
|
||||
items1, total = await user_crud.get_multi_with_total(
|
||||
items1, total = await user_repo.get_multi_with_total(
|
||||
session, skip=0, limit=2
|
||||
)
|
||||
assert len(items1) == 2
|
||||
assert total >= 3
|
||||
|
||||
# Get second page
|
||||
items2, total2 = await user_crud.get_multi_with_total(
|
||||
items2, total2 = await user_repo.get_multi_with_total(
|
||||
session, skip=2, limit=2
|
||||
)
|
||||
assert len(items2) >= 1
|
||||
@@ -594,7 +594,7 @@ class TestCRUDBaseGetMultiWithTotal:
|
||||
assert ids1.isdisjoint(ids2)
|
||||
|
||||
|
||||
class TestCRUDBaseCount:
|
||||
class TestRepositoryBaseCount:
|
||||
"""Tests for count method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -603,7 +603,7 @@ class TestCRUDBaseCount:
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
count = await user_crud.count(session)
|
||||
count = await user_repo.count(session)
|
||||
assert isinstance(count, int)
|
||||
assert count >= 1 # At least the test user
|
||||
|
||||
@@ -614,7 +614,7 @@ class TestCRUDBaseCount:
|
||||
|
||||
# Create additional users
|
||||
async with SessionLocal() as session:
|
||||
initial_count = await user_crud.count(session)
|
||||
initial_count = await user_repo.count(session)
|
||||
|
||||
user_data1 = UserCreate(
|
||||
email="count1@example.com",
|
||||
@@ -628,12 +628,12 @@ class TestCRUDBaseCount:
|
||||
first_name="Count",
|
||||
last_name="Two",
|
||||
)
|
||||
await user_crud.create(session, obj_in=user_data1)
|
||||
await user_crud.create(session, obj_in=user_data2)
|
||||
await user_repo.create(session, obj_in=user_data1)
|
||||
await user_repo.create(session, obj_in=user_data2)
|
||||
await session.commit()
|
||||
|
||||
async with SessionLocal() as session:
|
||||
new_count = await user_crud.count(session)
|
||||
new_count = await user_repo.count(session)
|
||||
assert new_count == initial_count + 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -644,10 +644,10 @@ class TestCRUDBaseCount:
|
||||
async with SessionLocal() as session:
|
||||
with patch.object(session, "execute", side_effect=Exception("DB error")):
|
||||
with pytest.raises(Exception, match="DB error"):
|
||||
await user_crud.count(session)
|
||||
await user_repo.count(session)
|
||||
|
||||
|
||||
class TestCRUDBaseExists:
|
||||
class TestRepositoryBaseExists:
|
||||
"""Tests for exists method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -656,7 +656,7 @@ class TestCRUDBaseExists:
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
result = await user_crud.exists(session, id=str(async_test_user.id))
|
||||
result = await user_repo.exists(session, id=str(async_test_user.id))
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -665,7 +665,7 @@ class TestCRUDBaseExists:
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
result = await user_crud.exists(session, id=str(uuid4()))
|
||||
result = await user_repo.exists(session, id=str(uuid4()))
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -674,11 +674,11 @@ class TestCRUDBaseExists:
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
result = await user_crud.exists(session, id="invalid-uuid")
|
||||
result = await user_repo.exists(session, id="invalid-uuid")
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestCRUDBaseSoftDelete:
|
||||
class TestRepositoryBaseSoftDelete:
|
||||
"""Tests for soft_delete method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -694,13 +694,13 @@ class TestCRUDBaseSoftDelete:
|
||||
first_name="Soft",
|
||||
last_name="Delete",
|
||||
)
|
||||
user = await user_crud.create(session, obj_in=user_data)
|
||||
user = await user_repo.create(session, obj_in=user_data)
|
||||
user_id = user.id
|
||||
await session.commit()
|
||||
|
||||
# Soft delete the user
|
||||
async with SessionLocal() as session:
|
||||
deleted = await user_crud.soft_delete(session, id=str(user_id))
|
||||
deleted = await user_repo.soft_delete(session, id=str(user_id))
|
||||
assert deleted is not None
|
||||
assert deleted.deleted_at is not None
|
||||
|
||||
@@ -710,7 +710,7 @@ class TestCRUDBaseSoftDelete:
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
result = await user_crud.soft_delete(session, id="invalid-uuid")
|
||||
result = await user_repo.soft_delete(session, id="invalid-uuid")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -719,7 +719,7 @@ class TestCRUDBaseSoftDelete:
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
result = await user_crud.soft_delete(session, id=str(uuid4()))
|
||||
result = await user_repo.soft_delete(session, id=str(uuid4()))
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -735,18 +735,18 @@ class TestCRUDBaseSoftDelete:
|
||||
first_name="Soft",
|
||||
last_name="Delete2",
|
||||
)
|
||||
user = await user_crud.create(session, obj_in=user_data)
|
||||
user = await user_repo.create(session, obj_in=user_data)
|
||||
user_id = user.id
|
||||
await session.commit()
|
||||
|
||||
# Soft delete with UUID object
|
||||
async with SessionLocal() as session:
|
||||
deleted = await user_crud.soft_delete(session, id=user_id) # UUID object
|
||||
deleted = await user_repo.soft_delete(session, id=user_id) # UUID object
|
||||
assert deleted is not None
|
||||
assert deleted.deleted_at is not None
|
||||
|
||||
|
||||
class TestCRUDBaseRestore:
|
||||
class TestRepositoryBaseRestore:
|
||||
"""Tests for restore method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -762,16 +762,16 @@ class TestCRUDBaseRestore:
|
||||
first_name="Restore",
|
||||
last_name="Test",
|
||||
)
|
||||
user = await user_crud.create(session, obj_in=user_data)
|
||||
user = await user_repo.create(session, obj_in=user_data)
|
||||
user_id = user.id
|
||||
await session.commit()
|
||||
|
||||
async with SessionLocal() as session:
|
||||
await user_crud.soft_delete(session, id=str(user_id))
|
||||
await user_repo.soft_delete(session, id=str(user_id))
|
||||
|
||||
# Restore the user
|
||||
async with SessionLocal() as session:
|
||||
restored = await user_crud.restore(session, id=str(user_id))
|
||||
restored = await user_repo.restore(session, id=str(user_id))
|
||||
assert restored is not None
|
||||
assert restored.deleted_at is None
|
||||
|
||||
@@ -781,7 +781,7 @@ class TestCRUDBaseRestore:
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
result = await user_crud.restore(session, id="invalid-uuid")
|
||||
result = await user_repo.restore(session, id="invalid-uuid")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -790,7 +790,7 @@ class TestCRUDBaseRestore:
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
result = await user_crud.restore(session, id=str(uuid4()))
|
||||
result = await user_repo.restore(session, id=str(uuid4()))
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -800,7 +800,7 @@ class TestCRUDBaseRestore:
|
||||
|
||||
async with SessionLocal() as session:
|
||||
# Try to restore a user that's not deleted
|
||||
result = await user_crud.restore(session, id=str(async_test_user.id))
|
||||
result = await user_repo.restore(session, id=str(async_test_user.id))
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -816,21 +816,21 @@ class TestCRUDBaseRestore:
|
||||
first_name="Restore",
|
||||
last_name="Test2",
|
||||
)
|
||||
user = await user_crud.create(session, obj_in=user_data)
|
||||
user = await user_repo.create(session, obj_in=user_data)
|
||||
user_id = user.id
|
||||
await session.commit()
|
||||
|
||||
async with SessionLocal() as session:
|
||||
await user_crud.soft_delete(session, id=str(user_id))
|
||||
await user_repo.soft_delete(session, id=str(user_id))
|
||||
|
||||
# Restore with UUID object
|
||||
async with SessionLocal() as session:
|
||||
restored = await user_crud.restore(session, id=user_id) # UUID object
|
||||
restored = await user_repo.restore(session, id=user_id) # UUID object
|
||||
assert restored is not None
|
||||
assert restored.deleted_at is None
|
||||
|
||||
|
||||
class TestCRUDBasePaginationValidation:
|
||||
class TestRepositoryBasePaginationValidation:
|
||||
"""Tests for pagination parameter validation (covers lines 254-260)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -840,7 +840,7 @@ class TestCRUDBasePaginationValidation:
|
||||
|
||||
async with SessionLocal() as session:
|
||||
with pytest.raises(InvalidInputError, match="skip must be non-negative"):
|
||||
await user_crud.get_multi_with_total(session, skip=-1, limit=10)
|
||||
await user_repo.get_multi_with_total(session, skip=-1, limit=10)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multi_with_total_negative_limit(self, async_test_db):
|
||||
@@ -849,7 +849,7 @@ class TestCRUDBasePaginationValidation:
|
||||
|
||||
async with SessionLocal() as session:
|
||||
with pytest.raises(InvalidInputError, match="limit must be non-negative"):
|
||||
await user_crud.get_multi_with_total(session, skip=0, limit=-1)
|
||||
await user_repo.get_multi_with_total(session, skip=0, limit=-1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multi_with_total_limit_too_large(self, async_test_db):
|
||||
@@ -858,7 +858,7 @@ class TestCRUDBasePaginationValidation:
|
||||
|
||||
async with SessionLocal() as session:
|
||||
with pytest.raises(InvalidInputError, match="Maximum limit is 1000"):
|
||||
await user_crud.get_multi_with_total(session, skip=0, limit=1001)
|
||||
await user_repo.get_multi_with_total(session, skip=0, limit=1001)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multi_with_total_with_filters(
|
||||
@@ -868,7 +868,7 @@ class TestCRUDBasePaginationValidation:
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
users, total = await user_crud.get_multi_with_total(
|
||||
users, total = await user_repo.get_multi_with_total(
|
||||
session, skip=0, limit=10, filters={"is_active": True}
|
||||
)
|
||||
assert isinstance(users, list)
|
||||
@@ -880,7 +880,7 @@ class TestCRUDBasePaginationValidation:
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
users, _total = await user_crud.get_multi_with_total(
|
||||
users, _total = await user_repo.get_multi_with_total(
|
||||
session, skip=0, limit=10, sort_by="created_at", sort_order="desc"
|
||||
)
|
||||
assert isinstance(users, list)
|
||||
@@ -891,13 +891,13 @@ class TestCRUDBasePaginationValidation:
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
users, _total = await user_crud.get_multi_with_total(
|
||||
users, _total = await user_repo.get_multi_with_total(
|
||||
session, skip=0, limit=10, sort_by="created_at", sort_order="asc"
|
||||
)
|
||||
assert isinstance(users, list)
|
||||
|
||||
|
||||
class TestCRUDBaseModelsWithoutSoftDelete:
|
||||
class TestRepositoryBaseModelsWithoutSoftDelete:
|
||||
"""
|
||||
Test soft_delete and restore on models without deleted_at column.
|
||||
Covers lines 342-343, 383-384 - error handling for unsupported models.
|
||||
@@ -912,7 +912,7 @@ class TestCRUDBaseModelsWithoutSoftDelete:
|
||||
|
||||
# Create an organization (which doesn't have deleted_at)
|
||||
from app.models.organization import Organization
|
||||
from app.repositories.organization import organization_repo as org_crud
|
||||
from app.repositories.organization import organization_repo as org_repo
|
||||
|
||||
async with SessionLocal() as session:
|
||||
org = Organization(name="Test Org", slug="test-org")
|
||||
@@ -925,7 +925,7 @@ class TestCRUDBaseModelsWithoutSoftDelete:
|
||||
with pytest.raises(
|
||||
InvalidInputError, match="does not have a deleted_at column"
|
||||
):
|
||||
await org_crud.soft_delete(session, id=str(org_id))
|
||||
await org_repo.soft_delete(session, id=str(org_id))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_model_without_deleted_at(self, async_test_db):
|
||||
@@ -934,7 +934,7 @@ class TestCRUDBaseModelsWithoutSoftDelete:
|
||||
|
||||
# Create an organization (which doesn't have deleted_at)
|
||||
from app.models.organization import Organization
|
||||
from app.repositories.organization import organization_repo as org_crud
|
||||
from app.repositories.organization import organization_repo as org_repo
|
||||
|
||||
async with SessionLocal() as session:
|
||||
org = Organization(name="Restore Test", slug="restore-test")
|
||||
@@ -947,10 +947,10 @@ class TestCRUDBaseModelsWithoutSoftDelete:
|
||||
with pytest.raises(
|
||||
InvalidInputError, match="does not have a deleted_at column"
|
||||
):
|
||||
await org_crud.restore(session, id=str(org_id))
|
||||
await org_repo.restore(session, id=str(org_id))
|
||||
|
||||
|
||||
class TestCRUDBaseEagerLoadingWithRealOptions:
|
||||
class TestRepositoryBaseEagerLoadingWithRealOptions:
|
||||
"""
|
||||
Test eager loading with actual SQLAlchemy load options.
|
||||
Covers lines 77-78, 119-120 - options loop execution.
|
||||
@@ -967,7 +967,7 @@ class TestCRUDBaseEagerLoadingWithRealOptions:
|
||||
|
||||
# Create a session for the user
|
||||
from app.models.user_session import UserSession
|
||||
from app.repositories.session import session_repo as session_crud
|
||||
from app.repositories.session import session_repo as session_repo
|
||||
|
||||
async with SessionLocal() as session:
|
||||
user_session = UserSession(
|
||||
@@ -985,7 +985,7 @@ class TestCRUDBaseEagerLoadingWithRealOptions:
|
||||
|
||||
# Get session with eager loading of user relationship
|
||||
async with SessionLocal() as session:
|
||||
result = await session_crud.get(
|
||||
result = await session_repo.get(
|
||||
session,
|
||||
id=str(session_id),
|
||||
options=[joinedload(UserSession.user)], # Real option, not empty list
|
||||
@@ -1006,7 +1006,7 @@ class TestCRUDBaseEagerLoadingWithRealOptions:
|
||||
|
||||
# Create multiple sessions for the user
|
||||
from app.models.user_session import UserSession
|
||||
from app.repositories.session import session_repo as session_crud
|
||||
from app.repositories.session import session_repo as session_repo
|
||||
|
||||
async with SessionLocal() as session:
|
||||
for i in range(3):
|
||||
@@ -1024,7 +1024,7 @@ class TestCRUDBaseEagerLoadingWithRealOptions:
|
||||
|
||||
# Get sessions with eager loading
|
||||
async with SessionLocal() as session:
|
||||
results = await session_crud.get_multi(
|
||||
results = await session_repo.get_multi(
|
||||
session,
|
||||
skip=0,
|
||||
limit=10,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# tests/crud/test_base_db_failures.py
|
||||
# tests/repositories/test_base_db_failures.py
|
||||
"""
|
||||
Comprehensive tests for base CRUD database failure scenarios.
|
||||
Comprehensive tests for base repository database failure scenarios.
|
||||
Tests exception handling, rollbacks, and error messages.
|
||||
"""
|
||||
|
||||
@@ -11,16 +11,16 @@ import pytest
|
||||
from sqlalchemy.exc import DataError, OperationalError
|
||||
|
||||
from app.core.repository_exceptions import IntegrityConstraintError
|
||||
from app.repositories.user import user_repo as user_crud
|
||||
from app.repositories.user import user_repo as user_repo
|
||||
from app.schemas.users import UserCreate
|
||||
|
||||
|
||||
class TestBaseCRUDCreateFailures:
|
||||
"""Test base CRUD create method exception handling."""
|
||||
class TestBaseRepositoryCreateFailures:
|
||||
"""Test base repository create method exception handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_operational_error_triggers_rollback(self, async_test_db):
|
||||
"""Test that OperationalError triggers rollback (User CRUD catches as Exception)."""
|
||||
"""Test that OperationalError triggers rollback (User repository catches as Exception)."""
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
@@ -41,16 +41,16 @@ class TestBaseCRUDCreateFailures:
|
||||
last_name="User",
|
||||
)
|
||||
|
||||
# User CRUD catches this as generic Exception and re-raises
|
||||
# User repository catches this as generic Exception and re-raises
|
||||
with pytest.raises(OperationalError):
|
||||
await user_crud.create(session, obj_in=user_data)
|
||||
await user_repo.create(session, obj_in=user_data)
|
||||
|
||||
# Verify rollback was called
|
||||
mock_rollback.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_data_error_triggers_rollback(self, async_test_db):
|
||||
"""Test that DataError triggers rollback (User CRUD catches as Exception)."""
|
||||
"""Test that DataError triggers rollback (User repository catches as Exception)."""
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
@@ -69,9 +69,9 @@ class TestBaseCRUDCreateFailures:
|
||||
last_name="User",
|
||||
)
|
||||
|
||||
# User CRUD catches this as generic Exception and re-raises
|
||||
# User repository catches this as generic Exception and re-raises
|
||||
with pytest.raises(DataError):
|
||||
await user_crud.create(session, obj_in=user_data)
|
||||
await user_repo.create(session, obj_in=user_data)
|
||||
|
||||
mock_rollback.assert_called_once()
|
||||
|
||||
@@ -97,13 +97,13 @@ class TestBaseCRUDCreateFailures:
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Unexpected database error"):
|
||||
await user_crud.create(session, obj_in=user_data)
|
||||
await user_repo.create(session, obj_in=user_data)
|
||||
|
||||
mock_rollback.assert_called_once()
|
||||
|
||||
|
||||
class TestBaseCRUDUpdateFailures:
|
||||
"""Test base CRUD update method exception handling."""
|
||||
class TestBaseRepositoryUpdateFailures:
|
||||
"""Test base repository update method exception handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_operational_error(self, async_test_db, async_test_user):
|
||||
@@ -111,7 +111,7 @@ class TestBaseCRUDUpdateFailures:
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
user = await user_crud.get(session, id=str(async_test_user.id))
|
||||
user = await user_repo.get(session, id=str(async_test_user.id))
|
||||
|
||||
async def mock_commit():
|
||||
raise OperationalError("Connection timeout", {}, Exception("Timeout"))
|
||||
@@ -123,7 +123,7 @@ class TestBaseCRUDUpdateFailures:
|
||||
with pytest.raises(
|
||||
IntegrityConstraintError, match="Database operation failed"
|
||||
):
|
||||
await user_crud.update(
|
||||
await user_repo.update(
|
||||
session, db_obj=user, obj_in={"first_name": "Updated"}
|
||||
)
|
||||
|
||||
@@ -135,7 +135,7 @@ class TestBaseCRUDUpdateFailures:
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
user = await user_crud.get(session, id=str(async_test_user.id))
|
||||
user = await user_repo.get(session, id=str(async_test_user.id))
|
||||
|
||||
async def mock_commit():
|
||||
raise DataError("Invalid data", {}, Exception("Data type mismatch"))
|
||||
@@ -147,7 +147,7 @@ class TestBaseCRUDUpdateFailures:
|
||||
with pytest.raises(
|
||||
IntegrityConstraintError, match="Database operation failed"
|
||||
):
|
||||
await user_crud.update(
|
||||
await user_repo.update(
|
||||
session, db_obj=user, obj_in={"first_name": "Updated"}
|
||||
)
|
||||
|
||||
@@ -159,7 +159,7 @@ class TestBaseCRUDUpdateFailures:
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
async with SessionLocal() as session:
|
||||
user = await user_crud.get(session, id=str(async_test_user.id))
|
||||
user = await user_repo.get(session, id=str(async_test_user.id))
|
||||
|
||||
async def mock_commit():
|
||||
raise KeyError("Unexpected error")
|
||||
@@ -169,15 +169,15 @@ class TestBaseCRUDUpdateFailures:
|
||||
session, "rollback", new_callable=AsyncMock
|
||||
) as mock_rollback:
|
||||
with pytest.raises(KeyError):
|
||||
await user_crud.update(
|
||||
await user_repo.update(
|
||||
session, db_obj=user, obj_in={"first_name": "Updated"}
|
||||
)
|
||||
|
||||
mock_rollback.assert_called_once()
|
||||
|
||||
|
||||
class TestBaseCRUDRemoveFailures:
|
||||
"""Test base CRUD remove method exception handling."""
|
||||
class TestBaseRepositoryRemoveFailures:
|
||||
"""Test base repository remove method exception handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_unexpected_error_triggers_rollback(
|
||||
@@ -196,12 +196,12 @@ class TestBaseCRUDRemoveFailures:
|
||||
session, "rollback", new_callable=AsyncMock
|
||||
) as mock_rollback:
|
||||
with pytest.raises(RuntimeError, match="Database write failed"):
|
||||
await user_crud.remove(session, id=str(async_test_user.id))
|
||||
await user_repo.remove(session, id=str(async_test_user.id))
|
||||
|
||||
mock_rollback.assert_called_once()
|
||||
|
||||
|
||||
class TestBaseCRUDGetMultiWithTotalFailures:
|
||||
class TestBaseRepositoryGetMultiWithTotalFailures:
|
||||
"""Test get_multi_with_total exception handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -217,10 +217,10 @@ class TestBaseCRUDGetMultiWithTotalFailures:
|
||||
|
||||
with patch.object(session, "execute", side_effect=mock_execute):
|
||||
with pytest.raises(OperationalError):
|
||||
await user_crud.get_multi_with_total(session, skip=0, limit=10)
|
||||
await user_repo.get_multi_with_total(session, skip=0, limit=10)
|
||||
|
||||
|
||||
class TestBaseCRUDCountFailures:
|
||||
class TestBaseRepositoryCountFailures:
|
||||
"""Test count method exception handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -235,10 +235,10 @@ class TestBaseCRUDCountFailures:
|
||||
|
||||
with patch.object(session, "execute", side_effect=mock_execute):
|
||||
with pytest.raises(OperationalError):
|
||||
await user_crud.count(session)
|
||||
await user_repo.count(session)
|
||||
|
||||
|
||||
class TestBaseCRUDSoftDeleteFailures:
|
||||
class TestBaseRepositorySoftDeleteFailures:
|
||||
"""Test soft_delete method exception handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -258,12 +258,12 @@ class TestBaseCRUDSoftDeleteFailures:
|
||||
session, "rollback", new_callable=AsyncMock
|
||||
) as mock_rollback:
|
||||
with pytest.raises(RuntimeError, match="Soft delete failed"):
|
||||
await user_crud.soft_delete(session, id=str(async_test_user.id))
|
||||
await user_repo.soft_delete(session, id=str(async_test_user.id))
|
||||
|
||||
mock_rollback.assert_called_once()
|
||||
|
||||
|
||||
class TestBaseCRUDRestoreFailures:
|
||||
class TestBaseRepositoryRestoreFailures:
|
||||
"""Test restore method exception handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -279,12 +279,12 @@ class TestBaseCRUDRestoreFailures:
|
||||
first_name="Restore",
|
||||
last_name="Test",
|
||||
)
|
||||
user = await user_crud.create(session, obj_in=user_data)
|
||||
user = await user_repo.create(session, obj_in=user_data)
|
||||
user_id = user.id
|
||||
await session.commit()
|
||||
|
||||
async with SessionLocal() as session:
|
||||
await user_crud.soft_delete(session, id=str(user_id))
|
||||
await user_repo.soft_delete(session, id=str(user_id))
|
||||
|
||||
# Now test restore failure
|
||||
async with SessionLocal() as session:
|
||||
@@ -297,12 +297,12 @@ class TestBaseCRUDRestoreFailures:
|
||||
session, "rollback", new_callable=AsyncMock
|
||||
) as mock_rollback:
|
||||
with pytest.raises(RuntimeError, match="Restore failed"):
|
||||
await user_crud.restore(session, id=str(user_id))
|
||||
await user_repo.restore(session, id=str(user_id))
|
||||
|
||||
mock_rollback.assert_called_once()
|
||||
|
||||
|
||||
class TestBaseCRUDGetFailures:
|
||||
class TestBaseRepositoryGetFailures:
|
||||
"""Test get method exception handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -317,10 +317,10 @@ class TestBaseCRUDGetFailures:
|
||||
|
||||
with patch.object(session, "execute", side_effect=mock_execute):
|
||||
with pytest.raises(OperationalError):
|
||||
await user_crud.get(session, id=str(uuid4()))
|
||||
await user_repo.get(session, id=str(uuid4()))
|
||||
|
||||
|
||||
class TestBaseCRUDGetMultiFailures:
|
||||
class TestBaseRepositoryGetMultiFailures:
|
||||
"""Test get_multi method exception handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -335,4 +335,4 @@ class TestBaseCRUDGetMultiFailures:
|
||||
|
||||
with patch.object(session, "execute", side_effect=mock_execute):
|
||||
with pytest.raises(OperationalError):
|
||||
await user_crud.get_multi(session, skip=0, limit=10)
|
||||
await user_repo.get_multi(session, skip=0, limit=10)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# tests/crud/test_oauth.py
|
||||
# tests/repositories/test_oauth.py
|
||||
"""
|
||||
Comprehensive tests for OAuth CRUD operations.
|
||||
Comprehensive tests for OAuth repository operations.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
@@ -14,8 +14,8 @@ from app.repositories.oauth_state import oauth_state_repo as oauth_state
|
||||
from app.schemas.oauth import OAuthAccountCreate, OAuthClientCreate, OAuthStateCreate
|
||||
|
||||
|
||||
class TestOAuthAccountCRUD:
|
||||
"""Tests for OAuth account CRUD operations."""
|
||||
class TestOAuthAccountRepository:
|
||||
"""Tests for OAuth account repository operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account(self, async_test_db, async_test_user):
|
||||
@@ -269,8 +269,8 @@ class TestOAuthAccountCRUD:
|
||||
assert updated.refresh_token == "new_refresh_token"
|
||||
|
||||
|
||||
class TestOAuthStateCRUD:
|
||||
"""Tests for OAuth state CRUD operations."""
|
||||
class TestOAuthStateRepository:
|
||||
"""Tests for OAuth state repository operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_state(self, async_test_db):
|
||||
@@ -376,8 +376,8 @@ class TestOAuthStateCRUD:
|
||||
assert result is not None
|
||||
|
||||
|
||||
class TestOAuthClientCRUD:
|
||||
"""Tests for OAuth client CRUD operations (provider mode)."""
|
||||
class TestOAuthClientRepository:
|
||||
"""Tests for OAuth client repository operations (provider mode)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_public_client(self, async_test_db):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# tests/crud/test_organization_async.py
|
||||
# tests/repositories/test_organization_async.py
|
||||
"""
|
||||
Comprehensive tests for async organization CRUD operations.
|
||||
Comprehensive tests for async organization repository operations.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
@@ -12,7 +12,7 @@ from sqlalchemy import select
|
||||
from app.core.repository_exceptions import DuplicateEntryError, IntegrityConstraintError
|
||||
from app.models.organization import Organization
|
||||
from app.models.user_organization import OrganizationRole, UserOrganization
|
||||
from app.repositories.organization import organization_repo as organization_crud
|
||||
from app.repositories.organization import organization_repo as organization_repo
|
||||
from app.schemas.organizations import OrganizationCreate
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class TestGetBySlug:
|
||||
|
||||
# Get by slug
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
result = await organization_crud.get_by_slug(session, slug="test-org")
|
||||
result = await organization_repo.get_by_slug(session, slug="test-org")
|
||||
assert result is not None
|
||||
assert result.id == org_id
|
||||
assert result.slug == "test-org"
|
||||
@@ -46,7 +46,7 @@ class TestGetBySlug:
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
result = await organization_crud.get_by_slug(session, slug="nonexistent")
|
||||
result = await organization_repo.get_by_slug(session, slug="nonexistent")
|
||||
assert result is None
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ class TestCreate:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_success(self, async_test_db):
|
||||
"""Test successfully creating an organization_crud."""
|
||||
"""Test successfully creating an organization_repo."""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
@@ -66,7 +66,7 @@ class TestCreate:
|
||||
is_active=True,
|
||||
settings={"key": "value"},
|
||||
)
|
||||
result = await organization_crud.create(session, obj_in=org_in)
|
||||
result = await organization_repo.create(session, obj_in=org_in)
|
||||
|
||||
assert result.name == "New Org"
|
||||
assert result.slug == "new-org"
|
||||
@@ -89,7 +89,7 @@ class TestCreate:
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
org_in = OrganizationCreate(name="Org 2", slug="duplicate-slug")
|
||||
with pytest.raises(DuplicateEntryError, match="already exists"):
|
||||
await organization_crud.create(session, obj_in=org_in)
|
||||
await organization_repo.create(session, obj_in=org_in)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_without_settings(self, async_test_db):
|
||||
@@ -98,7 +98,7 @@ class TestCreate:
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
org_in = OrganizationCreate(name="No Settings Org", slug="no-settings")
|
||||
result = await organization_crud.create(session, obj_in=org_in)
|
||||
result = await organization_repo.create(session, obj_in=org_in)
|
||||
|
||||
assert result.settings == {}
|
||||
|
||||
@@ -119,7 +119,7 @@ class TestGetMultiWithFilters:
|
||||
await session.commit()
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
orgs, total = await organization_crud.get_multi_with_filters(session)
|
||||
orgs, total = await organization_repo.get_multi_with_filters(session)
|
||||
assert total == 5
|
||||
assert len(orgs) == 5
|
||||
|
||||
@@ -135,7 +135,7 @@ class TestGetMultiWithFilters:
|
||||
await session.commit()
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
orgs, total = await organization_crud.get_multi_with_filters(
|
||||
orgs, total = await organization_repo.get_multi_with_filters(
|
||||
session, is_active=True
|
||||
)
|
||||
assert total == 1
|
||||
@@ -157,7 +157,7 @@ class TestGetMultiWithFilters:
|
||||
await session.commit()
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
orgs, total = await organization_crud.get_multi_with_filters(
|
||||
orgs, total = await organization_repo.get_multi_with_filters(
|
||||
session, search="tech"
|
||||
)
|
||||
assert total == 1
|
||||
@@ -175,7 +175,7 @@ class TestGetMultiWithFilters:
|
||||
await session.commit()
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
orgs, total = await organization_crud.get_multi_with_filters(
|
||||
orgs, total = await organization_repo.get_multi_with_filters(
|
||||
session, skip=2, limit=3
|
||||
)
|
||||
assert total == 10
|
||||
@@ -193,7 +193,7 @@ class TestGetMultiWithFilters:
|
||||
await session.commit()
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
orgs, _total = await organization_crud.get_multi_with_filters(
|
||||
orgs, _total = await organization_repo.get_multi_with_filters(
|
||||
session, sort_by="name", sort_order="asc"
|
||||
)
|
||||
assert orgs[0].name == "A Org"
|
||||
@@ -205,7 +205,7 @@ class TestGetMemberCount:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_member_count_success(self, async_test_db, async_test_user):
|
||||
"""Test getting member count for organization_crud."""
|
||||
"""Test getting member count for organization_repo."""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
@@ -225,7 +225,7 @@ class TestGetMemberCount:
|
||||
org_id = org.id
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
count = await organization_crud.get_member_count(
|
||||
count = await organization_repo.get_member_count(
|
||||
session, organization_id=org_id
|
||||
)
|
||||
assert count == 1
|
||||
@@ -242,7 +242,7 @@ class TestGetMemberCount:
|
||||
org_id = org.id
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
count = await organization_crud.get_member_count(
|
||||
count = await organization_repo.get_member_count(
|
||||
session, organization_id=org_id
|
||||
)
|
||||
assert count == 0
|
||||
@@ -253,7 +253,7 @@ class TestAddUser:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_user_success(self, async_test_db, async_test_user):
|
||||
"""Test successfully adding a user to organization_crud."""
|
||||
"""Test successfully adding a user to organization_repo."""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
@@ -263,7 +263,7 @@ class TestAddUser:
|
||||
org_id = org.id
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
result = await organization_crud.add_user(
|
||||
result = await organization_repo.add_user(
|
||||
session,
|
||||
organization_id=org_id,
|
||||
user_id=async_test_user.id,
|
||||
@@ -297,7 +297,7 @@ class TestAddUser:
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
with pytest.raises(DuplicateEntryError, match="already a member"):
|
||||
await organization_crud.add_user(
|
||||
await organization_repo.add_user(
|
||||
session, organization_id=org_id, user_id=async_test_user.id
|
||||
)
|
||||
|
||||
@@ -322,7 +322,7 @@ class TestAddUser:
|
||||
org_id = org.id
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
result = await organization_crud.add_user(
|
||||
result = await organization_repo.add_user(
|
||||
session,
|
||||
organization_id=org_id,
|
||||
user_id=async_test_user.id,
|
||||
@@ -338,7 +338,7 @@ class TestRemoveUser:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_user_success(self, async_test_db, async_test_user):
|
||||
"""Test successfully removing a user from organization_crud."""
|
||||
"""Test successfully removing a user from organization_repo."""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
@@ -357,7 +357,7 @@ class TestRemoveUser:
|
||||
org_id = org.id
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
result = await organization_crud.remove_user(
|
||||
result = await organization_repo.remove_user(
|
||||
session, organization_id=org_id, user_id=async_test_user.id
|
||||
)
|
||||
|
||||
@@ -385,7 +385,7 @@ class TestRemoveUser:
|
||||
org_id = org.id
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
result = await organization_crud.remove_user(
|
||||
result = await organization_repo.remove_user(
|
||||
session, organization_id=org_id, user_id=uuid4()
|
||||
)
|
||||
|
||||
@@ -416,7 +416,7 @@ class TestUpdateUserRole:
|
||||
org_id = org.id
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
result = await organization_crud.update_user_role(
|
||||
result = await organization_repo.update_user_role(
|
||||
session,
|
||||
organization_id=org_id,
|
||||
user_id=async_test_user.id,
|
||||
@@ -439,7 +439,7 @@ class TestUpdateUserRole:
|
||||
org_id = org.id
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
result = await organization_crud.update_user_role(
|
||||
result = await organization_repo.update_user_role(
|
||||
session,
|
||||
organization_id=org_id,
|
||||
user_id=uuid4(),
|
||||
@@ -475,7 +475,7 @@ class TestGetOrganizationMembers:
|
||||
org_id = org.id
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
members, total = await organization_crud.get_organization_members(
|
||||
members, total = await organization_repo.get_organization_members(
|
||||
session, organization_id=org_id
|
||||
)
|
||||
|
||||
@@ -508,7 +508,7 @@ class TestGetOrganizationMembers:
|
||||
org_id = org.id
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
members, total = await organization_crud.get_organization_members(
|
||||
members, total = await organization_repo.get_organization_members(
|
||||
session, organization_id=org_id, skip=0, limit=10
|
||||
)
|
||||
|
||||
@@ -539,7 +539,7 @@ class TestGetUserOrganizations:
|
||||
await session.commit()
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
orgs = await organization_crud.get_user_organizations(
|
||||
orgs = await organization_repo.get_user_organizations(
|
||||
session, user_id=async_test_user.id
|
||||
)
|
||||
|
||||
@@ -575,7 +575,7 @@ class TestGetUserOrganizations:
|
||||
await session.commit()
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
orgs = await organization_crud.get_user_organizations(
|
||||
orgs = await organization_repo.get_user_organizations(
|
||||
session, user_id=async_test_user.id, is_active=True
|
||||
)
|
||||
|
||||
@@ -588,7 +588,7 @@ class TestGetUserRole:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_role_in_org_success(self, async_test_db, async_test_user):
|
||||
"""Test getting user role in organization_crud."""
|
||||
"""Test getting user role in organization_repo."""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
@@ -607,7 +607,7 @@ class TestGetUserRole:
|
||||
org_id = org.id
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
role = await organization_crud.get_user_role_in_org(
|
||||
role = await organization_repo.get_user_role_in_org(
|
||||
session, user_id=async_test_user.id, organization_id=org_id
|
||||
)
|
||||
|
||||
@@ -625,7 +625,7 @@ class TestGetUserRole:
|
||||
org_id = org.id
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
role = await organization_crud.get_user_role_in_org(
|
||||
role = await organization_repo.get_user_role_in_org(
|
||||
session, user_id=uuid4(), organization_id=org_id
|
||||
)
|
||||
|
||||
@@ -656,7 +656,7 @@ class TestIsUserOrgOwner:
|
||||
org_id = org.id
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
is_owner = await organization_crud.is_user_org_owner(
|
||||
is_owner = await organization_repo.is_user_org_owner(
|
||||
session, user_id=async_test_user.id, organization_id=org_id
|
||||
)
|
||||
|
||||
@@ -683,7 +683,7 @@ class TestIsUserOrgOwner:
|
||||
org_id = org.id
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
is_owner = await organization_crud.is_user_org_owner(
|
||||
is_owner = await organization_repo.is_user_org_owner(
|
||||
session, user_id=async_test_user.id, organization_id=org_id
|
||||
)
|
||||
|
||||
@@ -720,7 +720,7 @@ class TestGetMultiWithMemberCounts:
|
||||
(
|
||||
orgs_with_counts,
|
||||
total,
|
||||
) = await organization_crud.get_multi_with_member_counts(session)
|
||||
) = await organization_repo.get_multi_with_member_counts(session)
|
||||
|
||||
assert total == 2
|
||||
assert len(orgs_with_counts) == 2
|
||||
@@ -745,7 +745,7 @@ class TestGetMultiWithMemberCounts:
|
||||
(
|
||||
orgs_with_counts,
|
||||
total,
|
||||
) = await organization_crud.get_multi_with_member_counts(
|
||||
) = await organization_repo.get_multi_with_member_counts(
|
||||
session, is_active=True
|
||||
)
|
||||
|
||||
@@ -767,7 +767,7 @@ class TestGetMultiWithMemberCounts:
|
||||
(
|
||||
orgs_with_counts,
|
||||
total,
|
||||
) = await organization_crud.get_multi_with_member_counts(
|
||||
) = await organization_repo.get_multi_with_member_counts(
|
||||
session, search="tech"
|
||||
)
|
||||
|
||||
@@ -801,7 +801,7 @@ class TestGetUserOrganizationsWithDetails:
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
orgs_with_details = (
|
||||
await organization_crud.get_user_organizations_with_details(
|
||||
await organization_repo.get_user_organizations_with_details(
|
||||
session, user_id=async_test_user.id
|
||||
)
|
||||
)
|
||||
@@ -841,7 +841,7 @@ class TestGetUserOrganizationsWithDetails:
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
orgs_with_details = (
|
||||
await organization_crud.get_user_organizations_with_details(
|
||||
await organization_repo.get_user_organizations_with_details(
|
||||
session, user_id=async_test_user.id, is_active=True
|
||||
)
|
||||
)
|
||||
@@ -874,7 +874,7 @@ class TestIsUserOrgAdmin:
|
||||
org_id = org.id
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
is_admin = await organization_crud.is_user_org_admin(
|
||||
is_admin = await organization_repo.is_user_org_admin(
|
||||
session, user_id=async_test_user.id, organization_id=org_id
|
||||
)
|
||||
|
||||
@@ -901,7 +901,7 @@ class TestIsUserOrgAdmin:
|
||||
org_id = org.id
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
is_admin = await organization_crud.is_user_org_admin(
|
||||
is_admin = await organization_repo.is_user_org_admin(
|
||||
session, user_id=async_test_user.id, organization_id=org_id
|
||||
)
|
||||
|
||||
@@ -928,7 +928,7 @@ class TestIsUserOrgAdmin:
|
||||
org_id = org.id
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
is_admin = await organization_crud.is_user_org_admin(
|
||||
is_admin = await organization_repo.is_user_org_admin(
|
||||
session, user_id=async_test_user.id, organization_id=org_id
|
||||
)
|
||||
|
||||
@@ -937,7 +937,7 @@ class TestIsUserOrgAdmin:
|
||||
|
||||
class TestOrganizationExceptionHandlers:
|
||||
"""
|
||||
Test exception handlers in organization CRUD methods.
|
||||
Test exception handlers in organization repository methods.
|
||||
Uses mocks to trigger database errors and verify proper error handling.
|
||||
Covers lines: 33-35, 57-62, 114-116, 130-132, 207-209, 258-260, 291-294, 326-329, 385-387, 409-411, 466-468, 491-493
|
||||
"""
|
||||
@@ -952,7 +952,7 @@ class TestOrganizationExceptionHandlers:
|
||||
session, "execute", side_effect=Exception("Database connection lost")
|
||||
):
|
||||
with pytest.raises(Exception, match="Database connection lost"):
|
||||
await organization_crud.get_by_slug(session, slug="test-slug")
|
||||
await organization_repo.get_by_slug(session, slug="test-slug")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_integrity_error_non_slug(self, async_test_db):
|
||||
@@ -976,7 +976,7 @@ class TestOrganizationExceptionHandlers:
|
||||
with pytest.raises(
|
||||
IntegrityConstraintError, match="Database integrity error"
|
||||
):
|
||||
await organization_crud.create(session, obj_in=org_in)
|
||||
await organization_repo.create(session, obj_in=org_in)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_unexpected_error(self, async_test_db):
|
||||
@@ -990,7 +990,7 @@ class TestOrganizationExceptionHandlers:
|
||||
with patch.object(session, "rollback", new_callable=AsyncMock):
|
||||
org_in = OrganizationCreate(name="Test", slug="test")
|
||||
with pytest.raises(RuntimeError, match="Unexpected error"):
|
||||
await organization_crud.create(session, obj_in=org_in)
|
||||
await organization_repo.create(session, obj_in=org_in)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_multi_with_filters_database_error(self, async_test_db):
|
||||
@@ -1002,7 +1002,7 @@ class TestOrganizationExceptionHandlers:
|
||||
session, "execute", side_effect=Exception("Query timeout")
|
||||
):
|
||||
with pytest.raises(Exception, match="Query timeout"):
|
||||
await organization_crud.get_multi_with_filters(session)
|
||||
await organization_repo.get_multi_with_filters(session)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_member_count_database_error(self, async_test_db):
|
||||
@@ -1016,7 +1016,7 @@ class TestOrganizationExceptionHandlers:
|
||||
session, "execute", side_effect=Exception("Count query failed")
|
||||
):
|
||||
with pytest.raises(Exception, match="Count query failed"):
|
||||
await organization_crud.get_member_count(
|
||||
await organization_repo.get_member_count(
|
||||
session, organization_id=uuid4()
|
||||
)
|
||||
|
||||
@@ -1030,7 +1030,7 @@ class TestOrganizationExceptionHandlers:
|
||||
session, "execute", side_effect=Exception("Complex query failed")
|
||||
):
|
||||
with pytest.raises(Exception, match="Complex query failed"):
|
||||
await organization_crud.get_multi_with_member_counts(session)
|
||||
await organization_repo.get_multi_with_member_counts(session)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_user_integrity_error(self, async_test_db, async_test_user):
|
||||
@@ -1064,7 +1064,7 @@ class TestOrganizationExceptionHandlers:
|
||||
IntegrityConstraintError,
|
||||
match="Failed to add user to organization",
|
||||
):
|
||||
await organization_crud.add_user(
|
||||
await organization_repo.add_user(
|
||||
session,
|
||||
organization_id=org_id,
|
||||
user_id=async_test_user.id,
|
||||
@@ -1082,7 +1082,7 @@ class TestOrganizationExceptionHandlers:
|
||||
session, "execute", side_effect=Exception("Delete failed")
|
||||
):
|
||||
with pytest.raises(Exception, match="Delete failed"):
|
||||
await organization_crud.remove_user(
|
||||
await organization_repo.remove_user(
|
||||
session, organization_id=uuid4(), user_id=async_test_user.id
|
||||
)
|
||||
|
||||
@@ -1100,7 +1100,7 @@ class TestOrganizationExceptionHandlers:
|
||||
session, "execute", side_effect=Exception("Update failed")
|
||||
):
|
||||
with pytest.raises(Exception, match="Update failed"):
|
||||
await organization_crud.update_user_role(
|
||||
await organization_repo.update_user_role(
|
||||
session,
|
||||
organization_id=uuid4(),
|
||||
user_id=async_test_user.id,
|
||||
@@ -1119,7 +1119,7 @@ class TestOrganizationExceptionHandlers:
|
||||
session, "execute", side_effect=Exception("Members query failed")
|
||||
):
|
||||
with pytest.raises(Exception, match="Members query failed"):
|
||||
await organization_crud.get_organization_members(
|
||||
await organization_repo.get_organization_members(
|
||||
session, organization_id=uuid4()
|
||||
)
|
||||
|
||||
@@ -1135,7 +1135,7 @@ class TestOrganizationExceptionHandlers:
|
||||
session, "execute", side_effect=Exception("User orgs query failed")
|
||||
):
|
||||
with pytest.raises(Exception, match="User orgs query failed"):
|
||||
await organization_crud.get_user_organizations(
|
||||
await organization_repo.get_user_organizations(
|
||||
session, user_id=async_test_user.id
|
||||
)
|
||||
|
||||
@@ -1151,7 +1151,7 @@ class TestOrganizationExceptionHandlers:
|
||||
session, "execute", side_effect=Exception("Details query failed")
|
||||
):
|
||||
with pytest.raises(Exception, match="Details query failed"):
|
||||
await organization_crud.get_user_organizations_with_details(
|
||||
await organization_repo.get_user_organizations_with_details(
|
||||
session, user_id=async_test_user.id
|
||||
)
|
||||
|
||||
@@ -1169,6 +1169,6 @@ class TestOrganizationExceptionHandlers:
|
||||
session, "execute", side_effect=Exception("Role query failed")
|
||||
):
|
||||
with pytest.raises(Exception, match="Role query failed"):
|
||||
await organization_crud.get_user_role_in_org(
|
||||
await organization_repo.get_user_role_in_org(
|
||||
session, user_id=async_test_user.id, organization_id=uuid4()
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# tests/crud/test_session_async.py
|
||||
# tests/repositories/test_session_async.py
|
||||
"""
|
||||
Comprehensive tests for async session CRUD operations.
|
||||
Comprehensive tests for async session repository operations.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
@@ -10,7 +10,7 @@ import pytest
|
||||
|
||||
from app.core.repository_exceptions import InvalidInputError
|
||||
from app.models.user_session import UserSession
|
||||
from app.repositories.session import session_repo as session_crud
|
||||
from app.repositories.session import session_repo as session_repo
|
||||
from app.schemas.sessions import SessionCreate
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ class TestGetByJti:
|
||||
await session.commit()
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
result = await session_crud.get_by_jti(session, jti="test_jti_123")
|
||||
result = await session_repo.get_by_jti(session, jti="test_jti_123")
|
||||
assert result is not None
|
||||
assert result.refresh_token_jti == "test_jti_123"
|
||||
|
||||
@@ -47,7 +47,7 @@ class TestGetByJti:
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
result = await session_crud.get_by_jti(session, jti="nonexistent")
|
||||
result = await session_repo.get_by_jti(session, jti="nonexistent")
|
||||
assert result is None
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ class TestGetActiveByJti:
|
||||
await session.commit()
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
result = await session_crud.get_active_by_jti(session, jti="active_jti")
|
||||
result = await session_repo.get_active_by_jti(session, jti="active_jti")
|
||||
assert result is not None
|
||||
assert result.is_active is True
|
||||
|
||||
@@ -98,7 +98,7 @@ class TestGetActiveByJti:
|
||||
await session.commit()
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
result = await session_crud.get_active_by_jti(session, jti="inactive_jti")
|
||||
result = await session_repo.get_active_by_jti(session, jti="inactive_jti")
|
||||
assert result is None
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ class TestGetUserSessions:
|
||||
await session.commit()
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
results = await session_crud.get_user_sessions(
|
||||
results = await session_repo.get_user_sessions(
|
||||
session, user_id=str(async_test_user.id), active_only=True
|
||||
)
|
||||
assert len(results) == 1
|
||||
@@ -162,7 +162,7 @@ class TestGetUserSessions:
|
||||
await session.commit()
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
results = await session_crud.get_user_sessions(
|
||||
results = await session_repo.get_user_sessions(
|
||||
session, user_id=str(async_test_user.id), active_only=False
|
||||
)
|
||||
assert len(results) == 3
|
||||
@@ -173,7 +173,7 @@ class TestCreateSession:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_success(self, async_test_db, async_test_user):
|
||||
"""Test successfully creating a session_crud."""
|
||||
"""Test successfully creating a session_repo."""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
@@ -189,7 +189,7 @@ class TestCreateSession:
|
||||
location_city="San Francisco",
|
||||
location_country="USA",
|
||||
)
|
||||
result = await session_crud.create_session(session, obj_in=session_data)
|
||||
result = await session_repo.create_session(session, obj_in=session_data)
|
||||
|
||||
assert result.user_id == async_test_user.id
|
||||
assert result.refresh_token_jti == "new_jti"
|
||||
@@ -202,7 +202,7 @@ class TestDeactivate:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deactivate_success(self, async_test_db, async_test_user):
|
||||
"""Test successfully deactivating a session_crud."""
|
||||
"""Test successfully deactivating a session_repo."""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
@@ -221,7 +221,7 @@ class TestDeactivate:
|
||||
session_id = user_session.id
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
result = await session_crud.deactivate(session, session_id=str(session_id))
|
||||
result = await session_repo.deactivate(session, session_id=str(session_id))
|
||||
assert result is not None
|
||||
assert result.is_active is False
|
||||
|
||||
@@ -231,7 +231,7 @@ class TestDeactivate:
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
result = await session_crud.deactivate(session, session_id=str(uuid4()))
|
||||
result = await session_repo.deactivate(session, session_id=str(uuid4()))
|
||||
assert result is None
|
||||
|
||||
|
||||
@@ -262,7 +262,7 @@ class TestDeactivateAllUserSessions:
|
||||
await session.commit()
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
count = await session_crud.deactivate_all_user_sessions(
|
||||
count = await session_repo.deactivate_all_user_sessions(
|
||||
session, user_id=str(async_test_user.id)
|
||||
)
|
||||
assert count == 2
|
||||
@@ -292,7 +292,7 @@ class TestUpdateLastUsed:
|
||||
await session.refresh(user_session)
|
||||
|
||||
old_time = user_session.last_used_at
|
||||
result = await session_crud.update_last_used(session, session=user_session)
|
||||
result = await session_repo.update_last_used(session, session=user_session)
|
||||
|
||||
assert result.last_used_at > old_time
|
||||
|
||||
@@ -321,7 +321,7 @@ class TestGetUserSessionCount:
|
||||
await session.commit()
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
count = await session_crud.get_user_session_count(
|
||||
count = await session_repo.get_user_session_count(
|
||||
session, user_id=str(async_test_user.id)
|
||||
)
|
||||
assert count == 3
|
||||
@@ -332,7 +332,7 @@ class TestGetUserSessionCount:
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
count = await session_crud.get_user_session_count(
|
||||
count = await session_repo.get_user_session_count(
|
||||
session, user_id=str(uuid4())
|
||||
)
|
||||
assert count == 0
|
||||
@@ -364,7 +364,7 @@ class TestUpdateRefreshToken:
|
||||
new_jti = "new_jti_123"
|
||||
new_expires = datetime.now(UTC) + timedelta(days=14)
|
||||
|
||||
result = await session_crud.update_refresh_token(
|
||||
result = await session_repo.update_refresh_token(
|
||||
session,
|
||||
session=user_session,
|
||||
new_jti=new_jti,
|
||||
@@ -410,7 +410,7 @@ class TestCleanupExpired:
|
||||
|
||||
# Cleanup
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
count = await session_crud.cleanup_expired(session, keep_days=30)
|
||||
count = await session_repo.cleanup_expired(session, keep_days=30)
|
||||
assert count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -436,7 +436,7 @@ class TestCleanupExpired:
|
||||
|
||||
# Cleanup
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
count = await session_crud.cleanup_expired(session, keep_days=30)
|
||||
count = await session_repo.cleanup_expired(session, keep_days=30)
|
||||
assert count == 0 # Should not delete recent sessions
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -462,7 +462,7 @@ class TestCleanupExpired:
|
||||
|
||||
# Cleanup
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
count = await session_crud.cleanup_expired(session, keep_days=30)
|
||||
count = await session_repo.cleanup_expired(session, keep_days=30)
|
||||
assert count == 0 # Should not delete active sessions
|
||||
|
||||
|
||||
@@ -493,7 +493,7 @@ class TestCleanupExpiredForUser:
|
||||
|
||||
# Cleanup for user
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
count = await session_crud.cleanup_expired_for_user(
|
||||
count = await session_repo.cleanup_expired_for_user(
|
||||
session, user_id=str(async_test_user.id)
|
||||
)
|
||||
assert count == 1
|
||||
@@ -505,7 +505,7 @@ class TestCleanupExpiredForUser:
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
with pytest.raises(InvalidInputError, match="Invalid user ID format"):
|
||||
await session_crud.cleanup_expired_for_user(
|
||||
await session_repo.cleanup_expired_for_user(
|
||||
session, user_id="not-a-valid-uuid"
|
||||
)
|
||||
|
||||
@@ -533,7 +533,7 @@ class TestCleanupExpiredForUser:
|
||||
|
||||
# Cleanup
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
count = await session_crud.cleanup_expired_for_user(
|
||||
count = await session_repo.cleanup_expired_for_user(
|
||||
session, user_id=str(async_test_user.id)
|
||||
)
|
||||
assert count == 0 # Should not delete active sessions
|
||||
@@ -565,7 +565,7 @@ class TestGetUserSessionsWithUser:
|
||||
|
||||
# Get with user relationship
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
results = await session_crud.get_user_sessions(
|
||||
results = await session_repo.get_user_sessions(
|
||||
session, user_id=str(async_test_user.id), with_user=True
|
||||
)
|
||||
assert len(results) >= 1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# tests/crud/test_session_db_failures.py
|
||||
# tests/repositories/test_session_db_failures.py
|
||||
"""
|
||||
Comprehensive tests for session CRUD database failure scenarios.
|
||||
Comprehensive tests for session repository database failure scenarios.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
@@ -12,11 +12,11 @@ from sqlalchemy.exc import OperationalError
|
||||
|
||||
from app.core.repository_exceptions import IntegrityConstraintError
|
||||
from app.models.user_session import UserSession
|
||||
from app.repositories.session import session_repo as session_crud
|
||||
from app.repositories.session import session_repo as session_repo
|
||||
from app.schemas.sessions import SessionCreate
|
||||
|
||||
|
||||
class TestSessionCRUDGetByJtiFailures:
|
||||
class TestSessionRepositoryGetByJtiFailures:
|
||||
"""Test get_by_jti exception handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -31,10 +31,10 @@ class TestSessionCRUDGetByJtiFailures:
|
||||
|
||||
with patch.object(session, "execute", side_effect=mock_execute):
|
||||
with pytest.raises(OperationalError):
|
||||
await session_crud.get_by_jti(session, jti="test_jti")
|
||||
await session_repo.get_by_jti(session, jti="test_jti")
|
||||
|
||||
|
||||
class TestSessionCRUDGetActiveByJtiFailures:
|
||||
class TestSessionRepositoryGetActiveByJtiFailures:
|
||||
"""Test get_active_by_jti exception handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -49,10 +49,10 @@ class TestSessionCRUDGetActiveByJtiFailures:
|
||||
|
||||
with patch.object(session, "execute", side_effect=mock_execute):
|
||||
with pytest.raises(OperationalError):
|
||||
await session_crud.get_active_by_jti(session, jti="test_jti")
|
||||
await session_repo.get_active_by_jti(session, jti="test_jti")
|
||||
|
||||
|
||||
class TestSessionCRUDGetUserSessionsFailures:
|
||||
class TestSessionRepositoryGetUserSessionsFailures:
|
||||
"""Test get_user_sessions exception handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -69,12 +69,12 @@ class TestSessionCRUDGetUserSessionsFailures:
|
||||
|
||||
with patch.object(session, "execute", side_effect=mock_execute):
|
||||
with pytest.raises(OperationalError):
|
||||
await session_crud.get_user_sessions(
|
||||
await session_repo.get_user_sessions(
|
||||
session, user_id=str(async_test_user.id)
|
||||
)
|
||||
|
||||
|
||||
class TestSessionCRUDCreateSessionFailures:
|
||||
class TestSessionRepositoryCreateSessionFailures:
|
||||
"""Test create_session exception handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -106,7 +106,7 @@ class TestSessionCRUDCreateSessionFailures:
|
||||
with pytest.raises(
|
||||
IntegrityConstraintError, match="Failed to create session"
|
||||
):
|
||||
await session_crud.create_session(session, obj_in=session_data)
|
||||
await session_repo.create_session(session, obj_in=session_data)
|
||||
|
||||
mock_rollback.assert_called_once()
|
||||
|
||||
@@ -139,12 +139,12 @@ class TestSessionCRUDCreateSessionFailures:
|
||||
with pytest.raises(
|
||||
IntegrityConstraintError, match="Failed to create session"
|
||||
):
|
||||
await session_crud.create_session(session, obj_in=session_data)
|
||||
await session_repo.create_session(session, obj_in=session_data)
|
||||
|
||||
mock_rollback.assert_called_once()
|
||||
|
||||
|
||||
class TestSessionCRUDDeactivateFailures:
|
||||
class TestSessionRepositoryDeactivateFailures:
|
||||
"""Test deactivate exception handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -182,14 +182,14 @@ class TestSessionCRUDDeactivateFailures:
|
||||
session, "rollback", new_callable=AsyncMock
|
||||
) as mock_rollback:
|
||||
with pytest.raises(OperationalError):
|
||||
await session_crud.deactivate(
|
||||
await session_repo.deactivate(
|
||||
session, session_id=str(session_id)
|
||||
)
|
||||
|
||||
mock_rollback.assert_called_once()
|
||||
|
||||
|
||||
class TestSessionCRUDDeactivateAllFailures:
|
||||
class TestSessionRepositoryDeactivateAllFailures:
|
||||
"""Test deactivate_all_user_sessions exception handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -209,14 +209,14 @@ class TestSessionCRUDDeactivateAllFailures:
|
||||
session, "rollback", new_callable=AsyncMock
|
||||
) as mock_rollback:
|
||||
with pytest.raises(OperationalError):
|
||||
await session_crud.deactivate_all_user_sessions(
|
||||
await session_repo.deactivate_all_user_sessions(
|
||||
session, user_id=str(async_test_user.id)
|
||||
)
|
||||
|
||||
mock_rollback.assert_called_once()
|
||||
|
||||
|
||||
class TestSessionCRUDUpdateLastUsedFailures:
|
||||
class TestSessionRepositoryUpdateLastUsedFailures:
|
||||
"""Test update_last_used exception handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -259,12 +259,12 @@ class TestSessionCRUDUpdateLastUsedFailures:
|
||||
session, "rollback", new_callable=AsyncMock
|
||||
) as mock_rollback:
|
||||
with pytest.raises(OperationalError):
|
||||
await session_crud.update_last_used(session, session=sess)
|
||||
await session_repo.update_last_used(session, session=sess)
|
||||
|
||||
mock_rollback.assert_called_once()
|
||||
|
||||
|
||||
class TestSessionCRUDUpdateRefreshTokenFailures:
|
||||
class TestSessionRepositoryUpdateRefreshTokenFailures:
|
||||
"""Test update_refresh_token exception handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -307,7 +307,7 @@ class TestSessionCRUDUpdateRefreshTokenFailures:
|
||||
session, "rollback", new_callable=AsyncMock
|
||||
) as mock_rollback:
|
||||
with pytest.raises(OperationalError):
|
||||
await session_crud.update_refresh_token(
|
||||
await session_repo.update_refresh_token(
|
||||
session,
|
||||
session=sess,
|
||||
new_jti=str(uuid4()),
|
||||
@@ -317,7 +317,7 @@ class TestSessionCRUDUpdateRefreshTokenFailures:
|
||||
mock_rollback.assert_called_once()
|
||||
|
||||
|
||||
class TestSessionCRUDCleanupExpiredFailures:
|
||||
class TestSessionRepositoryCleanupExpiredFailures:
|
||||
"""Test cleanup_expired exception handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -337,12 +337,12 @@ class TestSessionCRUDCleanupExpiredFailures:
|
||||
session, "rollback", new_callable=AsyncMock
|
||||
) as mock_rollback:
|
||||
with pytest.raises(OperationalError):
|
||||
await session_crud.cleanup_expired(session, keep_days=30)
|
||||
await session_repo.cleanup_expired(session, keep_days=30)
|
||||
|
||||
mock_rollback.assert_called_once()
|
||||
|
||||
|
||||
class TestSessionCRUDCleanupExpiredForUserFailures:
|
||||
class TestSessionRepositoryCleanupExpiredForUserFailures:
|
||||
"""Test cleanup_expired_for_user exception handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -362,14 +362,14 @@ class TestSessionCRUDCleanupExpiredForUserFailures:
|
||||
session, "rollback", new_callable=AsyncMock
|
||||
) as mock_rollback:
|
||||
with pytest.raises(OperationalError):
|
||||
await session_crud.cleanup_expired_for_user(
|
||||
await session_repo.cleanup_expired_for_user(
|
||||
session, user_id=str(async_test_user.id)
|
||||
)
|
||||
|
||||
mock_rollback.assert_called_once()
|
||||
|
||||
|
||||
class TestSessionCRUDGetUserSessionCountFailures:
|
||||
class TestSessionRepositoryGetUserSessionCountFailures:
|
||||
"""Test get_user_session_count exception handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -386,6 +386,6 @@ class TestSessionCRUDGetUserSessionCountFailures:
|
||||
|
||||
with patch.object(session, "execute", side_effect=mock_execute):
|
||||
with pytest.raises(OperationalError):
|
||||
await session_crud.get_user_session_count(
|
||||
await session_repo.get_user_session_count(
|
||||
session, user_id=str(async_test_user.id)
|
||||
)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# tests/crud/test_user_async.py
|
||||
# tests/repositories/test_user_async.py
|
||||
"""
|
||||
Comprehensive tests for async user CRUD operations.
|
||||
Comprehensive tests for async user repository operations.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.repository_exceptions import DuplicateEntryError, InvalidInputError
|
||||
from app.repositories.user import user_repo as user_crud
|
||||
from app.repositories.user import user_repo as user_repo
|
||||
from app.schemas.users import UserCreate, UserUpdate
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class TestGetByEmail:
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
result = await user_crud.get_by_email(session, email=async_test_user.email)
|
||||
result = await user_repo.get_by_email(session, email=async_test_user.email)
|
||||
assert result is not None
|
||||
assert result.email == async_test_user.email
|
||||
assert result.id == async_test_user.id
|
||||
@@ -30,7 +30,7 @@ class TestGetByEmail:
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
result = await user_crud.get_by_email(
|
||||
result = await user_repo.get_by_email(
|
||||
session, email="nonexistent@example.com"
|
||||
)
|
||||
assert result is None
|
||||
@@ -41,7 +41,7 @@ class TestCreate:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_success(self, async_test_db):
|
||||
"""Test successfully creating a user_crud."""
|
||||
"""Test successfully creating a user_repo."""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
@@ -52,7 +52,7 @@ class TestCreate:
|
||||
last_name="User",
|
||||
phone_number="+1234567890",
|
||||
)
|
||||
result = await user_crud.create(session, obj_in=user_data)
|
||||
result = await user_repo.create(session, obj_in=user_data)
|
||||
|
||||
assert result.email == "newuser@example.com"
|
||||
assert result.first_name == "New"
|
||||
@@ -76,7 +76,7 @@ class TestCreate:
|
||||
last_name="User",
|
||||
is_superuser=True,
|
||||
)
|
||||
result = await user_crud.create(session, obj_in=user_data)
|
||||
result = await user_repo.create(session, obj_in=user_data)
|
||||
|
||||
assert result.is_superuser is True
|
||||
assert result.email == "superuser@example.com"
|
||||
@@ -95,7 +95,7 @@ class TestCreate:
|
||||
)
|
||||
|
||||
with pytest.raises(DuplicateEntryError) as exc_info:
|
||||
await user_crud.create(session, obj_in=user_data)
|
||||
await user_repo.create(session, obj_in=user_data)
|
||||
|
||||
assert "already exists" in str(exc_info.value).lower()
|
||||
|
||||
@@ -110,12 +110,12 @@ class TestUpdate:
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
# Get fresh copy of user
|
||||
user = await user_crud.get(session, id=str(async_test_user.id))
|
||||
user = await user_repo.get(session, id=str(async_test_user.id))
|
||||
|
||||
update_data = UserUpdate(
|
||||
first_name="Updated", last_name="Name", phone_number="+9876543210"
|
||||
)
|
||||
result = await user_crud.update(session, db_obj=user, obj_in=update_data)
|
||||
result = await user_repo.update(session, db_obj=user, obj_in=update_data)
|
||||
|
||||
assert result.first_name == "Updated"
|
||||
assert result.last_name == "Name"
|
||||
@@ -134,16 +134,16 @@ class TestUpdate:
|
||||
first_name="Pass",
|
||||
last_name="Test",
|
||||
)
|
||||
user = await user_crud.create(session, obj_in=user_data)
|
||||
user = await user_repo.create(session, obj_in=user_data)
|
||||
user_id = user.id
|
||||
old_password_hash = user.password_hash
|
||||
|
||||
# Update the password
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
user = await user_crud.get(session, id=str(user_id))
|
||||
user = await user_repo.get(session, id=str(user_id))
|
||||
|
||||
update_data = UserUpdate(password="NewDifferentPassword123!")
|
||||
result = await user_crud.update(session, db_obj=user, obj_in=update_data)
|
||||
result = await user_repo.update(session, db_obj=user, obj_in=update_data)
|
||||
|
||||
await session.refresh(result)
|
||||
assert result.password_hash != old_password_hash
|
||||
@@ -158,10 +158,10 @@ class TestUpdate:
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
user = await user_crud.get(session, id=str(async_test_user.id))
|
||||
user = await user_repo.get(session, id=str(async_test_user.id))
|
||||
|
||||
update_dict = {"first_name": "DictUpdate"}
|
||||
result = await user_crud.update(session, db_obj=user, obj_in=update_dict)
|
||||
result = await user_repo.update(session, db_obj=user, obj_in=update_dict)
|
||||
|
||||
assert result.first_name == "DictUpdate"
|
||||
|
||||
@@ -175,7 +175,7 @@ class TestGetMultiWithTotal:
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
users, total = await user_crud.get_multi_with_total(
|
||||
users, total = await user_repo.get_multi_with_total(
|
||||
session, skip=0, limit=10
|
||||
)
|
||||
assert total >= 1
|
||||
@@ -196,10 +196,10 @@ class TestGetMultiWithTotal:
|
||||
first_name=f"User{i}",
|
||||
last_name="Test",
|
||||
)
|
||||
await user_crud.create(session, obj_in=user_data)
|
||||
await user_repo.create(session, obj_in=user_data)
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
users, _total = await user_crud.get_multi_with_total(
|
||||
users, _total = await user_repo.get_multi_with_total(
|
||||
session, skip=0, limit=10, sort_by="email", sort_order="asc"
|
||||
)
|
||||
|
||||
@@ -222,10 +222,10 @@ class TestGetMultiWithTotal:
|
||||
first_name=f"User{i}",
|
||||
last_name="Test",
|
||||
)
|
||||
await user_crud.create(session, obj_in=user_data)
|
||||
await user_repo.create(session, obj_in=user_data)
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
users, _total = await user_crud.get_multi_with_total(
|
||||
users, _total = await user_repo.get_multi_with_total(
|
||||
session, skip=0, limit=10, sort_by="email", sort_order="desc"
|
||||
)
|
||||
|
||||
@@ -247,7 +247,7 @@ class TestGetMultiWithTotal:
|
||||
first_name="Active",
|
||||
last_name="User",
|
||||
)
|
||||
await user_crud.create(session, obj_in=active_user)
|
||||
await user_repo.create(session, obj_in=active_user)
|
||||
|
||||
inactive_user = UserCreate(
|
||||
email="inactive@example.com",
|
||||
@@ -255,15 +255,15 @@ class TestGetMultiWithTotal:
|
||||
first_name="Inactive",
|
||||
last_name="User",
|
||||
)
|
||||
created_inactive = await user_crud.create(session, obj_in=inactive_user)
|
||||
created_inactive = await user_repo.create(session, obj_in=inactive_user)
|
||||
|
||||
# Deactivate the user
|
||||
await user_crud.update(
|
||||
await user_repo.update(
|
||||
session, db_obj=created_inactive, obj_in={"is_active": False}
|
||||
)
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
users, _total = await user_crud.get_multi_with_total(
|
||||
users, _total = await user_repo.get_multi_with_total(
|
||||
session, skip=0, limit=100, filters={"is_active": True}
|
||||
)
|
||||
|
||||
@@ -283,10 +283,10 @@ class TestGetMultiWithTotal:
|
||||
first_name="Searchable",
|
||||
last_name="UserName",
|
||||
)
|
||||
await user_crud.create(session, obj_in=user_data)
|
||||
await user_repo.create(session, obj_in=user_data)
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
users, total = await user_crud.get_multi_with_total(
|
||||
users, total = await user_repo.get_multi_with_total(
|
||||
session, skip=0, limit=100, search="Searchable"
|
||||
)
|
||||
|
||||
@@ -307,16 +307,16 @@ class TestGetMultiWithTotal:
|
||||
first_name=f"Page{i}",
|
||||
last_name="User",
|
||||
)
|
||||
await user_crud.create(session, obj_in=user_data)
|
||||
await user_repo.create(session, obj_in=user_data)
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
# Get first page
|
||||
users_page1, total = await user_crud.get_multi_with_total(
|
||||
users_page1, total = await user_repo.get_multi_with_total(
|
||||
session, skip=0, limit=2
|
||||
)
|
||||
|
||||
# Get second page
|
||||
users_page2, total2 = await user_crud.get_multi_with_total(
|
||||
users_page2, total2 = await user_repo.get_multi_with_total(
|
||||
session, skip=2, limit=2
|
||||
)
|
||||
|
||||
@@ -332,7 +332,7 @@ class TestGetMultiWithTotal:
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
with pytest.raises(InvalidInputError) as exc_info:
|
||||
await user_crud.get_multi_with_total(session, skip=-1, limit=10)
|
||||
await user_repo.get_multi_with_total(session, skip=-1, limit=10)
|
||||
|
||||
assert "skip must be non-negative" in str(exc_info.value)
|
||||
|
||||
@@ -343,7 +343,7 @@ class TestGetMultiWithTotal:
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
with pytest.raises(InvalidInputError) as exc_info:
|
||||
await user_crud.get_multi_with_total(session, skip=0, limit=-1)
|
||||
await user_repo.get_multi_with_total(session, skip=0, limit=-1)
|
||||
|
||||
assert "limit must be non-negative" in str(exc_info.value)
|
||||
|
||||
@@ -354,7 +354,7 @@ class TestGetMultiWithTotal:
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
with pytest.raises(InvalidInputError) as exc_info:
|
||||
await user_crud.get_multi_with_total(session, skip=0, limit=1001)
|
||||
await user_repo.get_multi_with_total(session, skip=0, limit=1001)
|
||||
|
||||
assert "Maximum limit is 1000" in str(exc_info.value)
|
||||
|
||||
@@ -377,12 +377,12 @@ class TestBulkUpdateStatus:
|
||||
first_name=f"Bulk{i}",
|
||||
last_name="User",
|
||||
)
|
||||
user = await user_crud.create(session, obj_in=user_data)
|
||||
user = await user_repo.create(session, obj_in=user_data)
|
||||
user_ids.append(user.id)
|
||||
|
||||
# Bulk deactivate
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
count = await user_crud.bulk_update_status(
|
||||
count = await user_repo.bulk_update_status(
|
||||
session, user_ids=user_ids, is_active=False
|
||||
)
|
||||
assert count == 3
|
||||
@@ -390,7 +390,7 @@ class TestBulkUpdateStatus:
|
||||
# Verify all are inactive
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
for user_id in user_ids:
|
||||
user = await user_crud.get(session, id=str(user_id))
|
||||
user = await user_repo.get(session, id=str(user_id))
|
||||
assert user.is_active is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -399,7 +399,7 @@ class TestBulkUpdateStatus:
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
count = await user_crud.bulk_update_status(
|
||||
count = await user_repo.bulk_update_status(
|
||||
session, user_ids=[], is_active=False
|
||||
)
|
||||
assert count == 0
|
||||
@@ -417,21 +417,21 @@ class TestBulkUpdateStatus:
|
||||
first_name="Reactivate",
|
||||
last_name="User",
|
||||
)
|
||||
user = await user_crud.create(session, obj_in=user_data)
|
||||
user = await user_repo.create(session, obj_in=user_data)
|
||||
# Deactivate
|
||||
await user_crud.update(session, db_obj=user, obj_in={"is_active": False})
|
||||
await user_repo.update(session, db_obj=user, obj_in={"is_active": False})
|
||||
user_id = user.id
|
||||
|
||||
# Reactivate
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
count = await user_crud.bulk_update_status(
|
||||
count = await user_repo.bulk_update_status(
|
||||
session, user_ids=[user_id], is_active=True
|
||||
)
|
||||
assert count == 1
|
||||
|
||||
# Verify active
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
user = await user_crud.get(session, id=str(user_id))
|
||||
user = await user_repo.get(session, id=str(user_id))
|
||||
assert user.is_active is True
|
||||
|
||||
|
||||
@@ -453,24 +453,24 @@ class TestBulkSoftDelete:
|
||||
first_name=f"Delete{i}",
|
||||
last_name="User",
|
||||
)
|
||||
user = await user_crud.create(session, obj_in=user_data)
|
||||
user = await user_repo.create(session, obj_in=user_data)
|
||||
user_ids.append(user.id)
|
||||
|
||||
# Bulk delete
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
count = await user_crud.bulk_soft_delete(session, user_ids=user_ids)
|
||||
count = await user_repo.bulk_soft_delete(session, user_ids=user_ids)
|
||||
assert count == 3
|
||||
|
||||
# Verify all are soft deleted
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
for user_id in user_ids:
|
||||
user = await user_crud.get(session, id=str(user_id))
|
||||
user = await user_repo.get(session, id=str(user_id))
|
||||
assert user.deleted_at is not None
|
||||
assert user.is_active is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_soft_delete_with_exclusion(self, async_test_db):
|
||||
"""Test bulk soft delete with excluded user_crud."""
|
||||
"""Test bulk soft delete with excluded user_repo."""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
# Create multiple users
|
||||
@@ -483,20 +483,20 @@ class TestBulkSoftDelete:
|
||||
first_name=f"Exclude{i}",
|
||||
last_name="User",
|
||||
)
|
||||
user = await user_crud.create(session, obj_in=user_data)
|
||||
user = await user_repo.create(session, obj_in=user_data)
|
||||
user_ids.append(user.id)
|
||||
|
||||
# Bulk delete, excluding first user
|
||||
exclude_id = user_ids[0]
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
count = await user_crud.bulk_soft_delete(
|
||||
count = await user_repo.bulk_soft_delete(
|
||||
session, user_ids=user_ids, exclude_user_id=exclude_id
|
||||
)
|
||||
assert count == 2 # Only 2 deleted
|
||||
|
||||
# Verify excluded user is NOT deleted
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
excluded_user = await user_crud.get(session, id=str(exclude_id))
|
||||
excluded_user = await user_repo.get(session, id=str(exclude_id))
|
||||
assert excluded_user.deleted_at is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -505,7 +505,7 @@ class TestBulkSoftDelete:
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
count = await user_crud.bulk_soft_delete(session, user_ids=[])
|
||||
count = await user_repo.bulk_soft_delete(session, user_ids=[])
|
||||
assert count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -521,12 +521,12 @@ class TestBulkSoftDelete:
|
||||
first_name="Only",
|
||||
last_name="User",
|
||||
)
|
||||
user = await user_crud.create(session, obj_in=user_data)
|
||||
user = await user_repo.create(session, obj_in=user_data)
|
||||
user_id = user.id
|
||||
|
||||
# Try to delete but exclude
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
count = await user_crud.bulk_soft_delete(
|
||||
count = await user_repo.bulk_soft_delete(
|
||||
session, user_ids=[user_id], exclude_user_id=user_id
|
||||
)
|
||||
assert count == 0
|
||||
@@ -544,15 +544,15 @@ class TestBulkSoftDelete:
|
||||
first_name="PreDeleted",
|
||||
last_name="User",
|
||||
)
|
||||
user = await user_crud.create(session, obj_in=user_data)
|
||||
user = await user_repo.create(session, obj_in=user_data)
|
||||
user_id = user.id
|
||||
|
||||
# First deletion
|
||||
await user_crud.bulk_soft_delete(session, user_ids=[user_id])
|
||||
await user_repo.bulk_soft_delete(session, user_ids=[user_id])
|
||||
|
||||
# Try to delete again
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
count = await user_crud.bulk_soft_delete(session, user_ids=[user_id])
|
||||
count = await user_repo.bulk_soft_delete(session, user_ids=[user_id])
|
||||
assert count == 0 # Already deleted
|
||||
|
||||
|
||||
@@ -561,16 +561,16 @@ class TestUtilityMethods:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_active_true(self, async_test_db, async_test_user):
|
||||
"""Test is_active returns True for active user_crud."""
|
||||
"""Test is_active returns True for active user_repo."""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
user = await user_crud.get(session, id=str(async_test_user.id))
|
||||
assert user_crud.is_active(user) is True
|
||||
user = await user_repo.get(session, id=str(async_test_user.id))
|
||||
assert user_repo.is_active(user) is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_active_false(self, async_test_db):
|
||||
"""Test is_active returns False for inactive user_crud."""
|
||||
"""Test is_active returns False for inactive user_repo."""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
@@ -580,10 +580,10 @@ class TestUtilityMethods:
|
||||
first_name="Inactive",
|
||||
last_name="User",
|
||||
)
|
||||
user = await user_crud.create(session, obj_in=user_data)
|
||||
await user_crud.update(session, db_obj=user, obj_in={"is_active": False})
|
||||
user = await user_repo.create(session, obj_in=user_data)
|
||||
await user_repo.update(session, db_obj=user, obj_in={"is_active": False})
|
||||
|
||||
assert user_crud.is_active(user) is False
|
||||
assert user_repo.is_active(user) is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_superuser_true(self, async_test_db, async_test_superuser):
|
||||
@@ -591,22 +591,22 @@ class TestUtilityMethods:
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
user = await user_crud.get(session, id=str(async_test_superuser.id))
|
||||
assert user_crud.is_superuser(user) is True
|
||||
user = await user_repo.get(session, id=str(async_test_superuser.id))
|
||||
assert user_repo.is_superuser(user) is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_superuser_false(self, async_test_db, async_test_user):
|
||||
"""Test is_superuser returns False for regular user_crud."""
|
||||
"""Test is_superuser returns False for regular user_repo."""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
async with AsyncTestingSessionLocal() as session:
|
||||
user = await user_crud.get(session, id=str(async_test_user.id))
|
||||
assert user_crud.is_superuser(user) is False
|
||||
user = await user_repo.get(session, id=str(async_test_user.id))
|
||||
assert user_repo.is_superuser(user) is False
|
||||
|
||||
|
||||
class TestUserExceptionHandlers:
|
||||
"""
|
||||
Test exception handlers in user CRUD methods.
|
||||
Test exception handlers in user repository methods.
|
||||
Covers lines: 30-32, 205-208, 257-260
|
||||
"""
|
||||
|
||||
@@ -622,7 +622,7 @@ class TestUserExceptionHandlers:
|
||||
session, "execute", side_effect=Exception("Database query failed")
|
||||
):
|
||||
with pytest.raises(Exception, match="Database query failed"):
|
||||
await user_crud.get_by_email(session, email="test@example.com")
|
||||
await user_repo.get_by_email(session, email="test@example.com")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_update_status_database_error(
|
||||
@@ -640,7 +640,7 @@ class TestUserExceptionHandlers:
|
||||
):
|
||||
with patch.object(session, "rollback", new_callable=AsyncMock):
|
||||
with pytest.raises(Exception, match="Bulk update failed"):
|
||||
await user_crud.bulk_update_status(
|
||||
await user_repo.bulk_update_status(
|
||||
session, user_ids=[async_test_user.id], is_active=False
|
||||
)
|
||||
|
||||
@@ -660,6 +660,6 @@ class TestUserExceptionHandlers:
|
||||
):
|
||||
with patch.object(session, "rollback", new_callable=AsyncMock):
|
||||
with pytest.raises(Exception, match="Bulk delete failed"):
|
||||
await user_crud.bulk_soft_delete(
|
||||
await user_repo.bulk_soft_delete(
|
||||
session, user_ids=[async_test_user.id]
|
||||
)
|
||||
|
||||
@@ -206,13 +206,13 @@ class TestCleanupExpiredSessions:
|
||||
"""Test cleanup returns 0 on database errors (doesn't crash)."""
|
||||
_test_engine, AsyncTestingSessionLocal = async_test_db
|
||||
|
||||
# Mock session_crud.cleanup_expired to raise error
|
||||
# Mock session_repo.cleanup_expired to raise error
|
||||
with patch(
|
||||
"app.services.session_cleanup.SessionLocal",
|
||||
return_value=AsyncTestingSessionLocal(),
|
||||
):
|
||||
with patch(
|
||||
"app.services.session_cleanup.session_crud.cleanup_expired"
|
||||
"app.services.session_cleanup.session_repo.cleanup_expired"
|
||||
) as mock_cleanup:
|
||||
mock_cleanup.side_effect = Exception("Database connection lost")
|
||||
|
||||
|
||||
@@ -91,9 +91,9 @@ class TestInitDb:
|
||||
"""Test that init_db handles database errors gracefully."""
|
||||
_test_engine, SessionLocal = async_test_db
|
||||
|
||||
# Mock user_crud.get_by_email to raise an exception
|
||||
# Mock user_repo.get_by_email to raise an exception
|
||||
with patch(
|
||||
"app.init_db.user_crud.get_by_email",
|
||||
"app.init_db.user_repo.get_by_email",
|
||||
side_effect=Exception("Database error"),
|
||||
):
|
||||
with patch("app.init_db.SessionLocal", SessionLocal):
|
||||
|
||||
95
backend/uv.lock
generated
95
backend/uv.lock
generated
@@ -549,18 +549,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.19.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email-validator"
|
||||
version = "2.3.0"
|
||||
@@ -599,13 +587,12 @@ dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "fastapi-utils" },
|
||||
{ name = "httpx" },
|
||||
{ name = "passlib" },
|
||||
{ name = "pillow" },
|
||||
{ name = "psycopg2-binary" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "python-jose" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "pytz" },
|
||||
{ name = "slowapi" },
|
||||
@@ -628,6 +615,7 @@ dev = [
|
||||
{ name = "pyright" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-benchmark" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-xdist" },
|
||||
{ name = "requests" },
|
||||
@@ -653,7 +641,6 @@ requires-dist = [
|
||||
{ name = "fastapi-utils", specifier = "==0.8.0" },
|
||||
{ name = "freezegun", marker = "extra == 'dev'", specifier = "~=1.5.1" },
|
||||
{ name = "httpx", specifier = ">=0.27.0" },
|
||||
{ name = "passlib", specifier = "==1.7.4" },
|
||||
{ name = "pillow", specifier = ">=12.1.1" },
|
||||
{ name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.7.0" },
|
||||
{ name = "pip-licenses", marker = "extra == 'dev'", specifier = ">=4.0.0" },
|
||||
@@ -661,13 +648,14 @@ requires-dist = [
|
||||
{ name = "psycopg2-binary", specifier = ">=2.9.9" },
|
||||
{ name = "pydantic", specifier = ">=2.10.6" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.2.1" },
|
||||
{ name = "pyjwt", specifier = ">=2.9.0" },
|
||||
{ name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.390" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.5" },
|
||||
{ name = "pytest-benchmark", marker = "extra == 'dev'", specifier = ">=4.0.0" },
|
||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" },
|
||||
{ name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.8.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.1" },
|
||||
{ name = "python-jose", specifier = "==3.4.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.22" },
|
||||
{ name = "pytz", specifier = ">=2024.1" },
|
||||
{ name = "requests", marker = "extra == 'dev'", specifier = ">=2.32.0" },
|
||||
@@ -1177,15 +1165,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "passlib"
|
||||
version = "1.7.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.1.1"
|
||||
@@ -1423,6 +1402,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "py-cpuinfo"
|
||||
version = "9.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "py-serializable"
|
||||
version = "2.1.0"
|
||||
@@ -1435,15 +1423,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.4.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a4/db/fffec68299e6d7bad3d504147f9094830b704527a7fc098b721d38cc7fa7/pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", size = 146820, upload-time = "2019-11-16T17:27:38.772Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/1e/a94a8d635fa3ce4cfc7f506003548d0a2447ae76fd5ca53932970fe3053f/pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", size = 77145, upload-time = "2019-11-16T17:27:11.07Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.23"
|
||||
@@ -1562,6 +1541,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyparsing"
|
||||
version = "3.3.2"
|
||||
@@ -1622,6 +1610,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-benchmark"
|
||||
version = "5.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "py-cpuinfo" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/34/9f732b76456d64faffbef6232f1f9dbec7a7c4999ff46282fa418bd1af66/pytest_benchmark-5.2.3.tar.gz", hash = "sha256:deb7317998a23c650fd4ff76e1230066a76cb45dcece0aca5607143c619e7779", size = 341340, upload-time = "2025-11-09T18:48:43.215Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.3-py3-none-any.whl", hash = "sha256:bc839726ad20e99aaa0d11a127445457b4219bdb9e80a1afc4b51da7f96b0803", size = 45255, upload-time = "2025-11-09T18:48:39.765Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "7.0.0"
|
||||
@@ -1696,20 +1697,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-jose"
|
||||
version = "3.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "ecdsa" },
|
||||
{ name = "pyasn1" },
|
||||
{ name = "rsa" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/a0/c49687cf40cb6128ea4e0559855aff92cd5ebd1a60a31c08526818c0e51e/python-jose-3.4.0.tar.gz", hash = "sha256:9a9a40f418ced8ecaf7e3b28d69887ceaa76adad3bcaa6dae0d9e596fec1d680", size = 92145, upload-time = "2025-02-18T17:26:41.985Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/b0/2586ea6b6fd57a994ece0b56418cbe93fff0efb85e2c9eb6b0caf24a4e37/python_jose-3.4.0-py2.py3-none-any.whl", hash = "sha256:9c9f616819652d109bd889ecd1e15e9a162b9b94d682534c9c2146092945b78f", size = 34616, upload-time = "2025-02-18T17:26:40.826Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.22"
|
||||
@@ -1934,18 +1921,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/7f/1a65ae870bc9d0576aebb0c501ea5dccf1ae2178fe2821042150ebd2e707/rpds_py-0.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2023473f444752f0f82a58dfcbee040d0a1b3d1b3c2ec40e884bd25db6d117d2", size = 225919, upload-time = "2025-11-16T14:50:14.734Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "4.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.14.4"
|
||||
|
||||
@@ -62,7 +62,7 @@ services:
|
||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
depends_on:
|
||||
- backend
|
||||
command: npm run dev
|
||||
command: bun run dev
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
|
||||
2
frontend/.gitignore
vendored
2
frontend/.gitignore
vendored
@@ -42,5 +42,5 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Auto-generated files (regenerate with npm run generate:api)
|
||||
# Auto-generated files (regenerate with bun run generate:api)
|
||||
/src/mocks/handlers/generated.ts
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# Stage 1: Dependencies
|
||||
FROM node:20-alpine AS deps
|
||||
FROM oven/bun:1-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Stage 2: Builder
|
||||
FROM node:20-alpine AS builder
|
||||
FROM oven/bun:1-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
RUN npm run build
|
||||
RUN bun run build
|
||||
|
||||
# Stage 3: Runner
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
@@ -29,7 +29,7 @@ Production-ready Next.js 16 frontend with TypeScript, authentication, admin pane
|
||||
|
||||
### Admin Panel
|
||||
|
||||
- 👥 **User Administration** - CRUD operations, search, filters
|
||||
- 👥 **User Administration** - Full lifecycle operations, search, filters
|
||||
- 🏢 **Organization Management** - Multi-tenant support with roles
|
||||
- 📊 **Dashboard** - Statistics and quick actions
|
||||
- 🔍 **Advanced Filtering** - Status, search, pagination
|
||||
@@ -47,16 +47,16 @@ Production-ready Next.js 16 frontend with TypeScript, authentication, admin pane
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- npm, yarn, or pnpm
|
||||
- [Bun](https://bun.sh/) (recommended runtime & package manager)
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
bun install
|
||||
|
||||
# Run development server
|
||||
npm run dev
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) to view the app.
|
||||
@@ -74,26 +74,26 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm run dev # Start dev server
|
||||
npm run build # Production build
|
||||
npm run start # Start production server
|
||||
bun run dev # Start dev server
|
||||
bun run build # Production build
|
||||
bun run start # Start production server
|
||||
|
||||
# Code Quality
|
||||
npm run lint # Run ESLint
|
||||
npm run format # Format with Prettier
|
||||
npm run format:check # Check formatting
|
||||
npm run type-check # TypeScript type checking
|
||||
npm run validate # Run all checks (lint + format + type-check)
|
||||
bun run lint # Run ESLint
|
||||
bun run format # Format with Prettier
|
||||
bun run format:check # Check formatting
|
||||
bun run type-check # TypeScript type checking
|
||||
bun run validate # Run all checks (lint + format + type-check)
|
||||
|
||||
# Testing
|
||||
npm test # Run unit tests
|
||||
npm run test:watch # Watch mode
|
||||
npm run test:coverage # Coverage report
|
||||
npm run test:e2e # Run E2E tests
|
||||
npm run test:e2e:ui # Playwright UI mode
|
||||
bun run test # Run unit tests
|
||||
bun run test:watch # Watch mode
|
||||
bun run test:coverage # Coverage report
|
||||
bun run test:e2e # Run E2E tests
|
||||
bun run test:e2e:ui # Playwright UI mode
|
||||
|
||||
# API Client
|
||||
npm run generate:api # Generate TypeScript client from OpenAPI spec
|
||||
bun run generate:api # Generate TypeScript client from OpenAPI spec
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
@@ -184,13 +184,13 @@ See [docs/I18N.md](./docs/I18N.md) for complete guide.
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm test
|
||||
bun run test
|
||||
|
||||
# Watch mode
|
||||
npm run test:watch
|
||||
bun run test:watch
|
||||
|
||||
# Coverage
|
||||
npm run test:coverage
|
||||
bun run test:coverage
|
||||
```
|
||||
|
||||
**Coverage**: 1,142+ tests covering components, hooks, utilities, and pages.
|
||||
@@ -199,13 +199,13 @@ npm run test:coverage
|
||||
|
||||
```bash
|
||||
# Run E2E tests
|
||||
npm run test:e2e
|
||||
bun run test:e2e
|
||||
|
||||
# UI mode (recommended for debugging)
|
||||
npm run test:e2e:ui
|
||||
bun run test:e2e:ui
|
||||
|
||||
# Debug mode
|
||||
npm run test:e2e:debug
|
||||
bun run test:e2e:debug
|
||||
```
|
||||
|
||||
**Coverage**: 178+ tests covering authentication, navigation, admin panel, and user flows.
|
||||
@@ -247,7 +247,7 @@ npm run test:e2e:debug
|
||||
|
||||
1. Follow existing code patterns
|
||||
2. Write tests for new features
|
||||
3. Run `npm run validate` before committing
|
||||
3. Run `bun run validate` before committing
|
||||
4. Keep translations in sync (en.json & it.json)
|
||||
|
||||
## License
|
||||
|
||||
2678
frontend/bun.lock
Normal file
2678
frontend/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -35,7 +35,7 @@
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run generate:api
|
||||
bun run generate:api
|
||||
```
|
||||
|
||||
This fetches the OpenAPI spec from the backend and generates TypeScript types and API client functions.
|
||||
@@ -894,7 +894,7 @@ apiClient.interceptors.request.use((config) => {
|
||||
**Solution**: Regenerate API client to sync with backend
|
||||
|
||||
```bash
|
||||
npm run generate:api
|
||||
bun run generate:api
|
||||
```
|
||||
|
||||
### 9.4 Stale Data
|
||||
|
||||
@@ -1300,7 +1300,7 @@ import Image from 'next/image';
|
||||
**Bundle Size Monitoring:**
|
||||
|
||||
```bash
|
||||
npm run build && npm run analyze
|
||||
bun run build && bun run analyze
|
||||
# Use webpack-bundle-analyzer to identify large dependencies
|
||||
```
|
||||
|
||||
@@ -1362,8 +1362,8 @@ npm run build && npm run analyze
|
||||
**Regular Audits:**
|
||||
|
||||
```bash
|
||||
npm audit
|
||||
npm audit fix
|
||||
bun audit
|
||||
bun audit fix
|
||||
```
|
||||
|
||||
**Automated Scanning:**
|
||||
@@ -1496,11 +1496,11 @@ npm audit fix
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
RUN bun install --frozen-lockfile --only=production
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
RUN bun run build
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "start"]
|
||||
CMD ["bun", "start"]
|
||||
```
|
||||
|
||||
### 14.2 Environment Configuration
|
||||
@@ -1536,15 +1536,15 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
run: bun run test
|
||||
- name: Run linter
|
||||
run: npm run lint
|
||||
run: bun run lint
|
||||
- name: Type check
|
||||
run: npm run type-check
|
||||
run: bun run type-check
|
||||
- name: Build
|
||||
run: npm run build
|
||||
run: bun run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -908,16 +908,16 @@ Before committing code, always run:
|
||||
|
||||
```bash
|
||||
# Type checking
|
||||
npm run type-check
|
||||
bun run type-check
|
||||
|
||||
# Linting
|
||||
npm run lint
|
||||
bun run lint
|
||||
|
||||
# Tests
|
||||
npm test
|
||||
bun run test
|
||||
|
||||
# Build check
|
||||
npm run build
|
||||
bun run build
|
||||
```
|
||||
|
||||
**In browser:**
|
||||
|
||||
@@ -59,7 +59,7 @@ cd frontend
|
||||
echo "NEXT_PUBLIC_DEMO_MODE=true" > .env.local
|
||||
|
||||
# Start frontend only (no backend needed)
|
||||
npm run dev
|
||||
bun run dev
|
||||
|
||||
# Open http://localhost:3000
|
||||
```
|
||||
@@ -233,7 +233,7 @@ MSW never initializes during Jest tests:
|
||||
- 97%+ coverage maintained
|
||||
|
||||
```bash
|
||||
npm test # MSW will NOT interfere
|
||||
bun run test # MSW will NOT interfere
|
||||
```
|
||||
|
||||
### E2E Tests (Playwright)
|
||||
@@ -247,14 +247,14 @@ MSW never initializes during Playwright tests:
|
||||
- All E2E tests pass unchanged
|
||||
|
||||
```bash
|
||||
npm run test:e2e # MSW will NOT interfere
|
||||
bun run test:e2e # MSW will NOT interfere
|
||||
```
|
||||
|
||||
### Manual Testing in Demo Mode
|
||||
|
||||
```bash
|
||||
# Enable demo mode
|
||||
NEXT_PUBLIC_DEMO_MODE=true npm run dev
|
||||
NEXT_PUBLIC_DEMO_MODE=true bun run dev
|
||||
|
||||
# Test flows:
|
||||
# 1. Open http://localhost:3000
|
||||
@@ -304,7 +304,7 @@ NEXT_PUBLIC_APP_NAME=My Demo App
|
||||
```bash
|
||||
# netlify.toml
|
||||
[build]
|
||||
command = "npm run build"
|
||||
command = "bun run build"
|
||||
publish = ".next"
|
||||
|
||||
[build.environment]
|
||||
@@ -321,10 +321,10 @@ module.exports = {
|
||||
}
|
||||
|
||||
# Build
|
||||
NEXT_PUBLIC_DEMO_MODE=true npm run build
|
||||
NEXT_PUBLIC_DEMO_MODE=true bun run build
|
||||
|
||||
# Deploy to GitHub Pages
|
||||
npm run deploy
|
||||
bun run deploy
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -1040,7 +1040,7 @@ export default function AdminDashboardPage() {
|
||||
|
||||
These examples demonstrate:
|
||||
|
||||
1. **Complete CRUD operations** (User Management)
|
||||
1. **Complete management operations** (User Management)
|
||||
2. **Real-time data with polling** (Session Management)
|
||||
3. **Data visualization** (Admin Dashboard Charts)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ MSW (Mock Service Worker) handlers are **automatically generated** from your Ope
|
||||
```
|
||||
Backend API Changes
|
||||
↓
|
||||
npm run generate:api
|
||||
bun run generate:api
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 1. Fetches OpenAPI spec │
|
||||
@@ -30,7 +30,7 @@ src/mocks/handlers/
|
||||
When you run:
|
||||
|
||||
```bash
|
||||
npm run generate:api
|
||||
bun run generate:api
|
||||
```
|
||||
|
||||
The system:
|
||||
@@ -125,7 +125,7 @@ Overrides are applied FIRST, so they take precedence over generated handlers.
|
||||
|
||||
```bash
|
||||
# Backend adds new endpoint
|
||||
# 1. Run npm run generate:api
|
||||
# 1. Run bun run generate:api
|
||||
# 2. Manually add MSW handler
|
||||
# 3. Test demo mode
|
||||
# 4. Fix bugs
|
||||
@@ -136,7 +136,7 @@ Overrides are applied FIRST, so they take precedence over generated handlers.
|
||||
|
||||
```bash
|
||||
# Backend adds new endpoint
|
||||
npm run generate:api # Done! MSW auto-synced
|
||||
bun run generate:api # Done! MSW auto-synced
|
||||
```
|
||||
|
||||
### ✅ Always In Sync
|
||||
@@ -202,11 +202,11 @@ frontend/
|
||||
2. **Regenerate clients:**
|
||||
```bash
|
||||
cd frontend
|
||||
npm run generate:api
|
||||
bun run generate:api
|
||||
```
|
||||
3. **Test demo mode:**
|
||||
```bash
|
||||
NEXT_PUBLIC_DEMO_MODE=true npm run dev
|
||||
NEXT_PUBLIC_DEMO_MODE=true bun run dev
|
||||
```
|
||||
4. **Done!** New endpoint automatically works in demo mode
|
||||
|
||||
@@ -286,7 +286,7 @@ The generator (`scripts/generate-msw-handlers.ts`) does:
|
||||
|
||||
**Check:**
|
||||
|
||||
1. Is backend running? (`npm run generate:api` requires backend)
|
||||
1. Is backend running? (`bun run generate:api` requires backend)
|
||||
2. Check console for `[MSW]` warnings
|
||||
3. Verify `generated.ts` exists and has your endpoint
|
||||
4. Check path parameters match exactly
|
||||
@@ -324,7 +324,7 @@ npx tsx scripts/generate-msw-handlers.ts /tmp/openapi.json
|
||||
|
||||
### ✅ Do
|
||||
|
||||
- Run `npm run generate:api` after backend changes
|
||||
- Run `bun run generate:api` after backend changes
|
||||
- Use `overrides.ts` for complex logic
|
||||
- Keep mock data in `data/` files
|
||||
- Test demo mode regularly
|
||||
@@ -380,7 +380,7 @@ http.get(`${API_BASE_URL}/api/v1/users/me`, async ({ request }) => {
|
||||
### After (Automated)
|
||||
|
||||
```bash
|
||||
npm run generate:api # Done! All 31+ endpoints handled automatically
|
||||
bun run generate:api # Done! All 31+ endpoints handled automatically
|
||||
```
|
||||
|
||||
**Manual Code: 1500+ lines**
|
||||
@@ -399,6 +399,6 @@ npm run generate:api # Done! All 31+ endpoints handled automatically
|
||||
|
||||
**This template is batteries-included.**
|
||||
Your API client and MSW handlers stay perfectly synchronized with zero manual work.
|
||||
Just run `npm run generate:api` and everything updates automatically.
|
||||
Just run `bun run generate:api` and everything updates automatically.
|
||||
|
||||
That's the power of OpenAPI + automation! 🚀
|
||||
|
||||
@@ -526,7 +526,7 @@ interface UserSession {
|
||||
- Development: `http://localhost:8000/api/v1/openapi.json`
|
||||
- Docker: `http://backend:8000/api/v1/openapi.json`
|
||||
- Generates TypeScript client in `src/lib/api/generated/`
|
||||
- Runs as npm script: `npm run generate:api`
|
||||
- Runs as script: `bun run generate:api`
|
||||
- Can be run independently for frontend-only development
|
||||
|
||||
**Root Script** (`root/scripts/generate-frontend-api.sh`):
|
||||
@@ -1724,7 +1724,7 @@ Provide 2-3 complete feature implementation walkthroughs, including:
|
||||
**Dependency Security:**
|
||||
|
||||
- Regular dependency updates
|
||||
- Security audit via `npm audit`
|
||||
- Security audit via `bun audit`
|
||||
- Automated security scanning (Dependabot, Snyk)
|
||||
|
||||
### 12.5 SEO
|
||||
@@ -1780,7 +1780,7 @@ The frontend template will be considered complete when:
|
||||
1. **Functionality:**
|
||||
- All specified pages are implemented and functional
|
||||
- Authentication flow works end-to-end
|
||||
- User and organization CRUD operations work
|
||||
- User and organization management operations work
|
||||
- API integration is complete and reliable
|
||||
|
||||
2. **Code Quality:**
|
||||
|
||||
19020
frontend/package-lock.json
generated
19020
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@
|
||||
"type-check": "tsc --noEmit",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"validate": "npm run lint && npm run format:check && npm run type-check",
|
||||
"validate": "bun run lint && bun run format:check && bun run type-check",
|
||||
"generate:api": "./scripts/generate-api-client.sh",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
@@ -24,7 +24,7 @@
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -32,65 +32,65 @@
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"axios": "^1.13.1",
|
||||
"axios": "^1.13.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.23.24",
|
||||
"framer-motion": "^12.34.3",
|
||||
"gray-matter": "^4.0.3",
|
||||
"lucide-react": "^0.552.0",
|
||||
"next": "^16",
|
||||
"next-intl": "^4.5.3",
|
||||
"next": "^16.1.6",
|
||||
"next-intl": "^4.8.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.66.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"recharts": "^2.15.4",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zod": "^3.25.76",
|
||||
"zustand": "^4.5.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "^0.86.11",
|
||||
"@next/bundle-analyzer": "^16.0.1",
|
||||
"@hey-api/openapi-ts": "^0.86.12",
|
||||
"@next/bundle-analyzer": "^16.1.6",
|
||||
"@peculiar/webcrypto": "^1.5.0",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@tanstack/react-query-devtools": "^5.91.3",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "^16",
|
||||
"@types/node": "^20.19.35",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-config-next": "^16.1.6",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"jest": "^30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"lighthouse": "^12.8.2",
|
||||
"msw": "^2.12.3",
|
||||
"prettier": "^3.6.2",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5",
|
||||
"typescript-eslint": "^8.15.0",
|
||||
"msw": "^2.12.10",
|
||||
"prettier": "^3.8.1",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"whatwg-fetch": "^3.6.20"
|
||||
},
|
||||
"msw": {
|
||||
@@ -98,8 +98,5 @@
|
||||
"public"
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"glob": "^10.4.1",
|
||||
"inflight": "npm:lru-cache@^10.0.0"
|
||||
}
|
||||
"overrides": {}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.12.7';
|
||||
const PACKAGE_VERSION = '2.12.10';
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82';
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse');
|
||||
const activeClientIds = new Set();
|
||||
|
||||
@@ -11,7 +11,7 @@ MSW handlers can drift out of sync with the backend API as it evolves.
|
||||
Install the package that auto-generates MSW handlers from OpenAPI:
|
||||
|
||||
```bash
|
||||
npm install --save-dev openapi-msw
|
||||
bun install --save-dev openapi-msw
|
||||
```
|
||||
|
||||
Then create a generation script:
|
||||
@@ -39,9 +39,9 @@ generate();
|
||||
When you add/change backend endpoints:
|
||||
|
||||
1. **Update Backend** → Make API changes
|
||||
2. **Generate Frontend Client** → `npm run generate:api`
|
||||
2. **Generate Frontend Client** → `bun run generate:api`
|
||||
3. **Update MSW Handlers** → Edit `src/mocks/handlers/*.ts`
|
||||
4. **Test Demo Mode** → `NEXT_PUBLIC_DEMO_MODE=true npm run dev`
|
||||
4. **Test Demo Mode** → `NEXT_PUBLIC_DEMO_MODE=true bun run dev`
|
||||
|
||||
### Option 3: Automated with Script Hook
|
||||
|
||||
@@ -50,7 +50,7 @@ Add to `package.json`:
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"generate:api": "./scripts/generate-api-client.sh && npm run sync:msw",
|
||||
"generate:api": "./scripts/generate-api-client.sh && bun run sync:msw",
|
||||
"sync:msw": "echo '⚠️ Don't forget to update MSW handlers in src/mocks/handlers/'"
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@ Our MSW handlers currently cover:
|
||||
|
||||
To check if MSW is missing handlers:
|
||||
|
||||
1. Start demo mode: `NEXT_PUBLIC_DEMO_MODE=true npm run dev`
|
||||
1. Start demo mode: `NEXT_PUBLIC_DEMO_MODE=true bun run dev`
|
||||
2. Open browser console
|
||||
3. Look for `[MSW] Warning: intercepted a request without a matching request handler`
|
||||
4. Add missing handlers to appropriate file in `src/mocks/handlers/`
|
||||
|
||||
@@ -152,7 +152,7 @@ type BuildUrlFn = <
|
||||
url: string;
|
||||
},
|
||||
>(
|
||||
options: Pick<TData, 'url'> & Options<TData>,
|
||||
options: TData & Options<TData>,
|
||||
) => string;
|
||||
|
||||
export type Client = CoreClient<
|
||||
@@ -195,7 +195,7 @@ export type Options<
|
||||
RequestOptions<TResponse, ThrowOnError>,
|
||||
'body' | 'path' | 'query' | 'url'
|
||||
> &
|
||||
Omit<TData, 'url'>;
|
||||
([TData] extends [never] ? unknown : Omit<TData, 'url'>);
|
||||
|
||||
export type OptionsLegacyParser<
|
||||
TData = unknown,
|
||||
|
||||
@@ -23,6 +23,17 @@ export type Field =
|
||||
*/
|
||||
key?: string;
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Field name. This is the name we want the user to see and use.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Field mapped name. This is the name we want to use in the request.
|
||||
* If `in` is omitted, `map` aliases `key` to the transport layer.
|
||||
*/
|
||||
map: Slot;
|
||||
};
|
||||
|
||||
export interface Fields {
|
||||
@@ -42,10 +53,14 @@ const extraPrefixes = Object.entries(extraPrefixesMap);
|
||||
|
||||
type KeyMap = Map<
|
||||
string,
|
||||
{
|
||||
| {
|
||||
in: Slot;
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
in?: never;
|
||||
map: Slot;
|
||||
}
|
||||
>;
|
||||
|
||||
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
|
||||
@@ -61,6 +76,10 @@ const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
|
||||
map: config.map,
|
||||
});
|
||||
}
|
||||
} else if ('key' in config) {
|
||||
map.set(config.key, {
|
||||
map: config.map,
|
||||
});
|
||||
} else if (config.args) {
|
||||
buildKeyMap(config.args, map);
|
||||
}
|
||||
@@ -112,7 +131,9 @@ export const buildClientParams = (
|
||||
if (config.key) {
|
||||
const field = map.get(config.key)!;
|
||||
const name = field.map || config.key;
|
||||
if (field.in) {
|
||||
(params[field.in] as Record<string, unknown>)[name] = arg;
|
||||
}
|
||||
} else {
|
||||
params.body = arg;
|
||||
}
|
||||
@@ -121,8 +142,12 @@ export const buildClientParams = (
|
||||
const field = map.get(key);
|
||||
|
||||
if (field) {
|
||||
if (field.in) {
|
||||
const name = field.map || key;
|
||||
(params[field.in] as Record<string, unknown>)[name] = value;
|
||||
} else {
|
||||
params[field.map] = value;
|
||||
}
|
||||
} else {
|
||||
const extra = extraPrefixes.find(([prefix]) =>
|
||||
key.startsWith(prefix),
|
||||
@@ -133,10 +158,8 @@ export const buildClientParams = (
|
||||
(params[slot] as Record<string, unknown>)[
|
||||
key.slice(prefix.length)
|
||||
] = value;
|
||||
} else {
|
||||
for (const [slot, allowed] of Object.entries(
|
||||
config.allowExtra ?? {},
|
||||
)) {
|
||||
} else if ('allowExtra' in config && config.allowExtra) {
|
||||
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
|
||||
if (allowed) {
|
||||
(params[slot as Slot] as Record<string, unknown>)[key] = value;
|
||||
break;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*
|
||||
* For custom handler behavior, use src/mocks/handlers/overrides.ts
|
||||
*
|
||||
* Generated: 2025-11-26T12:21:51.098Z
|
||||
* Generated: 2026-03-01T17:00:19.178Z
|
||||
*/
|
||||
|
||||
import { http, HttpResponse, delay } from 'msw';
|
||||
|
||||
Reference in New Issue
Block a user