add new
Some checks failed
K8S Fission Deployment / Deployment fission functions (push) Failing after 12s
Some checks failed
K8S Fission Deployment / Deployment fission functions (push) Failing after 12s
This commit is contained in:
623
.agents/skills/api-documentation-generator/SKILL.md
Normal file
623
.agents/skills/api-documentation-generator/SKILL.md
Normal file
@@ -0,0 +1,623 @@
|
||||
---
|
||||
name: api-documentation-generator
|
||||
description: Generate OpenAPI/Swagger specifications and API documentation from code or design. Use when creating API docs, generating OpenAPI specs, or documenting REST APIs.
|
||||
---
|
||||
|
||||
# API Documentation Generator
|
||||
|
||||
Generate OpenAPI/Swagger specifications and comprehensive API documentation.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Create OpenAPI 3.0 specs with paths, schemas, and examples for complete API documentation.
|
||||
|
||||
## Instructions
|
||||
|
||||
### OpenAPI 3.0 Structure
|
||||
|
||||
**Basic structure:**
|
||||
```yaml
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: API Name
|
||||
version: 1.0.0
|
||||
description: API description
|
||||
servers:
|
||||
- url: https://api.example.com/v1
|
||||
paths:
|
||||
/users:
|
||||
get:
|
||||
summary: List users
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
components:
|
||||
schemas:
|
||||
User:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
```
|
||||
|
||||
### Info Section
|
||||
|
||||
```yaml
|
||||
info:
|
||||
title: E-commerce API
|
||||
version: 1.0.0
|
||||
description: |
|
||||
REST API for e-commerce platform.
|
||||
|
||||
## Authentication
|
||||
Use Bearer token in Authorization header.
|
||||
|
||||
## Rate Limiting
|
||||
1000 requests per hour per API key.
|
||||
contact:
|
||||
name: API Support
|
||||
email: api@example.com
|
||||
url: https://example.com/support
|
||||
license:
|
||||
name: MIT
|
||||
url: https://opensource.org/licenses/MIT
|
||||
```
|
||||
|
||||
### Servers
|
||||
|
||||
```yaml
|
||||
servers:
|
||||
- url: https://api.example.com/v1
|
||||
description: Production
|
||||
- url: https://staging-api.example.com/v1
|
||||
description: Staging
|
||||
- url: http://localhost:3000/v1
|
||||
description: Development
|
||||
```
|
||||
|
||||
### Paths and Operations
|
||||
|
||||
**GET endpoint:**
|
||||
```yaml
|
||||
paths:
|
||||
/users:
|
||||
get:
|
||||
summary: List users
|
||||
description: Retrieve a paginated list of users
|
||||
tags:
|
||||
- Users
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 1
|
||||
- name: per_page
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 20
|
||||
responses:
|
||||
'200':
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/User'
|
||||
meta:
|
||||
$ref: '#/components/schemas/PaginationMeta'
|
||||
```
|
||||
|
||||
**POST endpoint:**
|
||||
```yaml
|
||||
/users:
|
||||
post:
|
||||
summary: Create user
|
||||
tags:
|
||||
- Users
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateUserRequest'
|
||||
example:
|
||||
name: John Doe
|
||||
email: john@example.com
|
||||
responses:
|
||||
'201':
|
||||
description: User created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/User'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
```
|
||||
|
||||
**Path parameters:**
|
||||
```yaml
|
||||
/users/{id}:
|
||||
get:
|
||||
summary: Get user by ID
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
description: User ID
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/User'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
```
|
||||
|
||||
### Components - Schemas
|
||||
|
||||
**Simple schema:**
|
||||
```yaml
|
||||
components:
|
||||
schemas:
|
||||
User:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- email
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
example: 1
|
||||
name:
|
||||
type: string
|
||||
example: John Doe
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
example: john@example.com
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
```
|
||||
|
||||
**Nested schema:**
|
||||
```yaml
|
||||
Order:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
customer:
|
||||
$ref: '#/components/schemas/User'
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/OrderItem'
|
||||
total:
|
||||
type: number
|
||||
format: float
|
||||
```
|
||||
|
||||
**Enum:**
|
||||
```yaml
|
||||
OrderStatus:
|
||||
type: string
|
||||
enum:
|
||||
- pending
|
||||
- processing
|
||||
- shipped
|
||||
- delivered
|
||||
- cancelled
|
||||
```
|
||||
|
||||
**OneOf (union types):**
|
||||
```yaml
|
||||
Payment:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/CreditCardPayment'
|
||||
- $ref: '#/components/schemas/PayPalPayment'
|
||||
discriminator:
|
||||
propertyName: payment_type
|
||||
```
|
||||
|
||||
### Components - Responses
|
||||
|
||||
**Reusable responses:**
|
||||
```yaml
|
||||
components:
|
||||
responses:
|
||||
NotFound:
|
||||
description: Resource not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
example:
|
||||
error:
|
||||
code: NOT_FOUND
|
||||
message: Resource not found
|
||||
|
||||
BadRequest:
|
||||
description: Invalid request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
|
||||
Unauthorized:
|
||||
description: Authentication required
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
```
|
||||
|
||||
### Components - Parameters
|
||||
|
||||
**Reusable parameters:**
|
||||
```yaml
|
||||
components:
|
||||
parameters:
|
||||
PageParam:
|
||||
name: page
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 1
|
||||
|
||||
LimitParam:
|
||||
name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 20
|
||||
maximum: 100
|
||||
|
||||
IdParam:
|
||||
name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
```
|
||||
|
||||
### Security Schemes
|
||||
|
||||
**Bearer token:**
|
||||
```yaml
|
||||
components:
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
|
||||
security:
|
||||
- BearerAuth: []
|
||||
```
|
||||
|
||||
**API Key:**
|
||||
```yaml
|
||||
components:
|
||||
securitySchemes:
|
||||
ApiKeyAuth:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-API-Key
|
||||
```
|
||||
|
||||
**OAuth 2.0:**
|
||||
```yaml
|
||||
components:
|
||||
securitySchemes:
|
||||
OAuth2:
|
||||
type: oauth2
|
||||
flows:
|
||||
authorizationCode:
|
||||
authorizationUrl: https://example.com/oauth/authorize
|
||||
tokenUrl: https://example.com/oauth/token
|
||||
scopes:
|
||||
read: Read access
|
||||
write: Write access
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
**Multiple examples:**
|
||||
```yaml
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/User'
|
||||
examples:
|
||||
admin:
|
||||
summary: Admin user
|
||||
value:
|
||||
id: 1
|
||||
name: Admin
|
||||
role: admin
|
||||
regular:
|
||||
summary: Regular user
|
||||
value:
|
||||
id: 2
|
||||
name: John
|
||||
role: user
|
||||
```
|
||||
|
||||
### Tags
|
||||
|
||||
**Organize endpoints:**
|
||||
```yaml
|
||||
tags:
|
||||
- name: Users
|
||||
description: User management
|
||||
- name: Products
|
||||
description: Product catalog
|
||||
- name: Orders
|
||||
description: Order processing
|
||||
|
||||
paths:
|
||||
/users:
|
||||
get:
|
||||
tags:
|
||||
- Users
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```yaml
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: Blog API
|
||||
version: 1.0.0
|
||||
description: RESTful API for blog platform
|
||||
|
||||
servers:
|
||||
- url: https://api.blog.com/v1
|
||||
|
||||
paths:
|
||||
/posts:
|
||||
get:
|
||||
summary: List posts
|
||||
tags:
|
||||
- Posts
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/PageParam'
|
||||
- $ref: '#/components/parameters/LimitParam'
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Post'
|
||||
meta:
|
||||
$ref: '#/components/schemas/PaginationMeta'
|
||||
|
||||
post:
|
||||
summary: Create post
|
||||
tags:
|
||||
- Posts
|
||||
security:
|
||||
- BearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- title
|
||||
- content
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
content:
|
||||
type: string
|
||||
tags:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
responses:
|
||||
'201':
|
||||
description: Post created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Post'
|
||||
|
||||
/posts/{id}:
|
||||
get:
|
||||
summary: Get post
|
||||
tags:
|
||||
- Posts
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Post'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
components:
|
||||
schemas:
|
||||
Post:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
title:
|
||||
type: string
|
||||
content:
|
||||
type: string
|
||||
author:
|
||||
$ref: '#/components/schemas/User'
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
User:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
|
||||
PaginationMeta:
|
||||
type: object
|
||||
properties:
|
||||
total:
|
||||
type: integer
|
||||
page:
|
||||
type: integer
|
||||
per_page:
|
||||
type: integer
|
||||
|
||||
Error:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
|
||||
parameters:
|
||||
PageParam:
|
||||
name: page
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 1
|
||||
|
||||
LimitParam:
|
||||
name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 20
|
||||
|
||||
responses:
|
||||
NotFound:
|
||||
description: Resource not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
|
||||
security:
|
||||
- BearerAuth: []
|
||||
```
|
||||
|
||||
## Generating from Code
|
||||
|
||||
### From Express.js
|
||||
|
||||
```javascript
|
||||
// Use swagger-jsdoc
|
||||
/**
|
||||
* @swagger
|
||||
* /users:
|
||||
* get:
|
||||
* summary: List users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
*/
|
||||
app.get('/users', (req, res) => {
|
||||
// Handler
|
||||
});
|
||||
```
|
||||
|
||||
### From FastAPI (Python)
|
||||
|
||||
```python
|
||||
# FastAPI auto-generates OpenAPI
|
||||
@app.get("/users", response_model=List[User])
|
||||
async def list_users():
|
||||
return users
|
||||
```
|
||||
|
||||
### From ASP.NET Core
|
||||
|
||||
```csharp
|
||||
// Use Swashbuckle
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<User>), 200)]
|
||||
public IActionResult GetUsers()
|
||||
{
|
||||
return Ok(users);
|
||||
}
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
**Swagger Editor**: https://editor.swagger.io
|
||||
**Swagger UI**: Interactive documentation
|
||||
**Redoc**: Alternative documentation UI
|
||||
**Postman**: Import OpenAPI for testing
|
||||
|
||||
## Best Practices
|
||||
|
||||
**Use $ref for reusability:**
|
||||
- Define schemas once
|
||||
- Reference in multiple places
|
||||
- Easier maintenance
|
||||
|
||||
**Include examples:**
|
||||
- Help developers understand
|
||||
- Enable better testing
|
||||
- Show expected formats
|
||||
|
||||
**Document errors:**
|
||||
- All possible status codes
|
||||
- Error response format
|
||||
- Error codes and meanings
|
||||
|
||||
**Version your API:**
|
||||
- Include version in URL or header
|
||||
- Document breaking changes
|
||||
- Maintain old versions
|
||||
|
||||
**Keep it updated:**
|
||||
- Generate from code when possible
|
||||
- Review regularly
|
||||
- Update with API changes
|
||||
187
.agents/skills/pytest/SKILL.md
Normal file
187
.agents/skills/pytest/SKILL.md
Normal file
@@ -0,0 +1,187 @@
|
||||
---
|
||||
name: pytest
|
||||
description: |
|
||||
Python testing with pytest framework for unit, integration, and API tests.
|
||||
Use when: (1) Writing test cases for Python code, (2) Setting up pytest fixtures,
|
||||
(3) Testing async functions with pytest-asyncio, (4) Mocking dependencies,
|
||||
(5) Parameterizing tests, (6) Testing FastAPI/Flask endpoints, (7) Setting up test coverage,
|
||||
(8) Creating test factories with factory_boy, (9) Configuring CI/CD test pipelines.
|
||||
---
|
||||
|
||||
# Pytest Testing Skill
|
||||
|
||||
Comprehensive testing patterns for Python applications using pytest.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Feature | Reference File |
|
||||
|---------|----------------|
|
||||
| Fixtures, conftest, scopes | [references/fixtures.md](references/fixtures.md) |
|
||||
| Async testing, pytest-asyncio | [references/async-testing.md](references/async-testing.md) |
|
||||
| Mocking, patching, spies | [references/mocking.md](references/mocking.md) |
|
||||
| FastAPI/Flask endpoint testing | [references/api-testing.md](references/api-testing.md) |
|
||||
|
||||
## Dependencies
|
||||
|
||||
```toml
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"httpx>=0.28.0", # Async HTTP client for API tests
|
||||
"factory-boy>=3.3.0", # Test factories
|
||||
"faker>=33.0.0", # Fake data generation
|
||||
]
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### pyproject.toml
|
||||
|
||||
```toml
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_functions = ["test_*"]
|
||||
addopts = "-v --tb=short"
|
||||
filterwarnings = ["ignore::DeprecationWarning"]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["app"]
|
||||
omit = ["*/tests/*", "*/__init__.py"]
|
||||
```
|
||||
|
||||
## Basic Test Structure
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
# Simple test function
|
||||
def test_addition():
|
||||
assert 1 + 1 == 2
|
||||
|
||||
# Test class (group related tests)
|
||||
class TestCalculator:
|
||||
def test_add(self):
|
||||
assert add(2, 3) == 5
|
||||
|
||||
def test_subtract(self):
|
||||
assert subtract(5, 3) == 2
|
||||
|
||||
# Expected exceptions
|
||||
def test_division_by_zero():
|
||||
with pytest.raises(ZeroDivisionError):
|
||||
divide(1, 0)
|
||||
|
||||
# Parametrized tests
|
||||
@pytest.mark.parametrize("input,expected", [
|
||||
(1, 1),
|
||||
(2, 4),
|
||||
(3, 9),
|
||||
])
|
||||
def test_square(input, expected):
|
||||
assert square(input) == expected
|
||||
```
|
||||
|
||||
## Fixtures
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
@pytest.fixture
|
||||
def sample_user():
|
||||
return {"name": "John", "email": "john@example.com"}
|
||||
|
||||
@pytest.fixture
|
||||
def db_connection():
|
||||
conn = create_connection()
|
||||
yield conn # Test runs here
|
||||
conn.close() # Cleanup after test
|
||||
|
||||
# Use fixture in test
|
||||
def test_user_name(sample_user):
|
||||
assert sample_user["name"] == "John"
|
||||
```
|
||||
|
||||
## Async Testing
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_function():
|
||||
result = await async_operation()
|
||||
assert result == "success"
|
||||
|
||||
@pytest.fixture
|
||||
async def async_client():
|
||||
async with AsyncClient() as client:
|
||||
yield client
|
||||
```
|
||||
|
||||
## Mocking
|
||||
|
||||
```python
|
||||
from unittest.mock import patch, MagicMock, AsyncMock
|
||||
|
||||
def test_with_mock():
|
||||
with patch("module.external_api") as mock_api:
|
||||
mock_api.return_value = {"data": "mocked"}
|
||||
result = function_using_api()
|
||||
assert result["data"] == "mocked"
|
||||
mock_api.assert_called_once()
|
||||
|
||||
# Async mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_mock():
|
||||
with patch("module.async_call", new_callable=AsyncMock) as mock:
|
||||
mock.return_value = "result"
|
||||
result = await function_with_async_call()
|
||||
assert result == "result"
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Verbose output
|
||||
pytest -v
|
||||
|
||||
# Run specific file
|
||||
pytest tests/test_users.py
|
||||
|
||||
# Run specific test
|
||||
pytest tests/test_users.py::test_create_user
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=app --cov-report=term-missing
|
||||
|
||||
# Stop on first failure
|
||||
pytest -x
|
||||
|
||||
# Run last failed tests
|
||||
pytest --lf
|
||||
|
||||
# Run tests matching pattern
|
||||
pytest -k "user and not delete"
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
project/
|
||||
├── app/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py
|
||||
│ └── services/
|
||||
├── tests/
|
||||
│ ├── __init__.py
|
||||
│ ├── conftest.py # Shared fixtures
|
||||
│ ├── test_main.py
|
||||
│ └── test_services/
|
||||
└── pyproject.toml
|
||||
```
|
||||
434
.agents/skills/pytest/references/api-testing.md
Normal file
434
.agents/skills/pytest/references/api-testing.md
Normal file
@@ -0,0 +1,434 @@
|
||||
# API Testing
|
||||
|
||||
## Table of Contents
|
||||
- [FastAPI Testing](#fastapi-testing)
|
||||
- [Flask Testing](#flask-testing)
|
||||
- [Test Factories](#test-factories)
|
||||
- [Authentication Testing](#authentication-testing)
|
||||
- [Database Testing](#database-testing)
|
||||
|
||||
## FastAPI Testing
|
||||
|
||||
### Setup
|
||||
|
||||
```python
|
||||
# tests/conftest.py
|
||||
import pytest
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from app.main import app
|
||||
from app.core.database import get_session
|
||||
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///./test.db"
|
||||
|
||||
test_engine = create_async_engine(TEST_DATABASE_URL)
|
||||
TestSession = async_sessionmaker(test_engine, class_=AsyncSession)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
async def db_session():
|
||||
"""Fresh database for each test."""
|
||||
async with test_engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
|
||||
async with TestSession() as session:
|
||||
yield session
|
||||
|
||||
async with test_engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.drop_all)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(db_session):
|
||||
"""Test client with dependency override."""
|
||||
async def override_session():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_session] = override_session
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
```
|
||||
|
||||
### Basic Endpoint Tests
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
class TestUsers:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user(self, client: AsyncClient):
|
||||
response = await client.post(
|
||||
"/api/users",
|
||||
json={"email": "test@example.com", "name": "Test User"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["email"] == "test@example.com"
|
||||
assert "id" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user(self, client: AsyncClient, test_user):
|
||||
response = await client.get(f"/api/users/{test_user.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == test_user.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_not_found(self, client: AsyncClient):
|
||||
response = await client.get("/api/users/99999")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user(self, client: AsyncClient, test_user, auth_headers):
|
||||
response = await client.patch(
|
||||
f"/api/users/{test_user.id}",
|
||||
json={"name": "Updated Name"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "Updated Name"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_user(self, client: AsyncClient, test_user, admin_headers):
|
||||
response = await client.delete(
|
||||
f"/api/users/{test_user.id}",
|
||||
headers=admin_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
```
|
||||
|
||||
### Testing Query Parameters
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users_pagination(self, client: AsyncClient, auth_headers):
|
||||
response = await client.get(
|
||||
"/api/users",
|
||||
params={"page": 1, "per_page": 10, "sort": "-created_at"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
assert data["page"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_users(self, client: AsyncClient, auth_headers):
|
||||
response = await client.get(
|
||||
"/api/users",
|
||||
params={"q": "john", "active": True},
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
```
|
||||
|
||||
### Testing File Uploads
|
||||
|
||||
```python
|
||||
import io
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_file(self, client: AsyncClient, auth_headers):
|
||||
file_content = b"Hello, World!"
|
||||
files = {"file": ("test.txt", io.BytesIO(file_content), "text/plain")}
|
||||
|
||||
response = await client.post(
|
||||
"/api/files/upload",
|
||||
files=files,
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["filename"] == "test.txt"
|
||||
```
|
||||
|
||||
## Flask Testing
|
||||
|
||||
### Setup
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from app import create_app
|
||||
from app.extensions import db
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create application for testing."""
|
||||
app = create_app("testing")
|
||||
app.config["TESTING"] = True
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
yield app
|
||||
db.drop_all()
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Test client."""
|
||||
return app.test_client()
|
||||
|
||||
@pytest.fixture
|
||||
def runner(app):
|
||||
"""CLI test runner."""
|
||||
return app.test_cli_runner()
|
||||
```
|
||||
|
||||
### Flask Test Examples
|
||||
|
||||
```python
|
||||
def test_create_user(client):
|
||||
response = client.post(
|
||||
"/api/users",
|
||||
json={"email": "test@example.com", "name": "Test"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
def test_get_user(client, test_user):
|
||||
response = client.get(f"/api/users/{test_user.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json["email"] == test_user.email
|
||||
|
||||
def test_protected_route(client, auth_token):
|
||||
response = client.get(
|
||||
"/api/protected",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
```
|
||||
|
||||
## Test Factories
|
||||
|
||||
### Using factory_boy
|
||||
|
||||
```python
|
||||
# tests/factories.py
|
||||
import factory
|
||||
from faker import Faker
|
||||
from app.models import User, Project
|
||||
|
||||
fake = Faker()
|
||||
|
||||
|
||||
class UserFactory(factory.Factory):
|
||||
class Meta:
|
||||
model = User
|
||||
|
||||
email = factory.LazyAttribute(lambda _: fake.email())
|
||||
name = factory.LazyAttribute(lambda _: fake.name())
|
||||
hashed_password = "hashed_password"
|
||||
is_active = True
|
||||
|
||||
|
||||
class ProjectFactory(factory.Factory):
|
||||
class Meta:
|
||||
model = Project
|
||||
|
||||
name = factory.LazyAttribute(lambda _: fake.company())
|
||||
description = factory.LazyAttribute(lambda _: fake.sentence())
|
||||
owner_id = None
|
||||
|
||||
|
||||
# Async helper
|
||||
async def create_user(session, **kwargs) -> User:
|
||||
user = UserFactory(**kwargs)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def create_users(session, count: int = 5, **kwargs) -> list[User]:
|
||||
users = [UserFactory(**kwargs) for _ in range(count)]
|
||||
session.add_all(users)
|
||||
await session.commit()
|
||||
return users
|
||||
```
|
||||
|
||||
### Using Factories in Tests
|
||||
|
||||
```python
|
||||
from tests.factories import create_user, create_users
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users(client, db_session, auth_headers):
|
||||
# Create 20 users
|
||||
await create_users(db_session, count=20)
|
||||
|
||||
response = await client.get("/api/users", headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["total"] >= 20
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_active_users(client, db_session, auth_headers):
|
||||
await create_user(db_session, is_active=True)
|
||||
await create_user(db_session, is_active=False)
|
||||
|
||||
response = await client.get(
|
||||
"/api/users",
|
||||
params={"active": True},
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
assert all(u["is_active"] for u in data["items"])
|
||||
```
|
||||
|
||||
## Authentication Testing
|
||||
|
||||
### Auth Fixtures
|
||||
|
||||
```python
|
||||
from app.core.security import create_access_token, hash_password
|
||||
|
||||
@pytest.fixture
|
||||
async def test_user(db_session):
|
||||
"""Create a regular test user."""
|
||||
user = User(
|
||||
email="test@example.com",
|
||||
hashed_password=hash_password("password123"),
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
return user
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_user(db_session):
|
||||
"""Create an admin user."""
|
||||
user = User(
|
||||
email="admin@example.com",
|
||||
hashed_password=hash_password("admin123"),
|
||||
is_active=True,
|
||||
is_admin=True,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
return user
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(test_user):
|
||||
"""Headers with valid auth token."""
|
||||
token = create_access_token({"sub": str(test_user.id)})
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
@pytest.fixture
|
||||
def admin_headers(admin_user):
|
||||
"""Headers with admin auth token."""
|
||||
token = create_access_token({"sub": str(admin_user.id)})
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
```
|
||||
|
||||
### Auth Test Examples
|
||||
|
||||
```python
|
||||
class TestAuth:
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_success(self, client, test_user):
|
||||
response = await client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": test_user.email, "password": "password123"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "access_token" in response.json()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_wrong_password(self, client, test_user):
|
||||
response = await client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": test_user.email, "password": "wrong"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_protected_endpoint_no_auth(self, client):
|
||||
response = await client.get("/api/protected")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_endpoint_regular_user(self, client, auth_headers):
|
||||
response = await client.delete(
|
||||
"/api/admin/users/1",
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
```
|
||||
|
||||
## Database Testing
|
||||
|
||||
### Transaction Rollback Pattern
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
async def db_session(engine):
|
||||
"""Each test runs in a transaction that rolls back."""
|
||||
async with engine.connect() as conn:
|
||||
await conn.begin()
|
||||
|
||||
async with AsyncSession(bind=conn) as session:
|
||||
yield session
|
||||
|
||||
await conn.rollback()
|
||||
```
|
||||
|
||||
### Testing Repository Layer
|
||||
|
||||
```python
|
||||
from app.repositories import UserRepository
|
||||
|
||||
class TestUserRepository:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create(self, db_session):
|
||||
repo = UserRepository(db_session)
|
||||
|
||||
user = await repo.create({
|
||||
"email": "test@example.com",
|
||||
"hashed_password": "hash",
|
||||
})
|
||||
|
||||
assert user.id is not None
|
||||
assert user.email == "test@example.com"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_email(self, db_session, test_user):
|
||||
repo = UserRepository(db_session)
|
||||
|
||||
user = await repo.get_by_email(test_user.email)
|
||||
|
||||
assert user.id == test_user.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_soft_delete(self, db_session, test_user):
|
||||
repo = UserRepository(db_session)
|
||||
|
||||
await repo.soft_delete(test_user.id)
|
||||
|
||||
user = await repo.get_by_id(test_user.id)
|
||||
assert user.status == 0 # Soft deleted
|
||||
```
|
||||
290
.agents/skills/pytest/references/async-testing.md
Normal file
290
.agents/skills/pytest/references/async-testing.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# Async Testing
|
||||
|
||||
## Table of Contents
|
||||
- [Setup](#setup)
|
||||
- [Basic Async Tests](#basic-async-tests)
|
||||
- [Async Fixtures](#async-fixtures)
|
||||
- [Testing Async Generators](#testing-async-generators)
|
||||
- [Timeouts](#timeouts)
|
||||
|
||||
## Setup
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
pip install pytest-asyncio
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
```toml
|
||||
# pyproject.toml
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto" # Automatically handle async tests
|
||||
# OR
|
||||
asyncio_mode = "strict" # Require explicit @pytest.mark.asyncio
|
||||
```
|
||||
|
||||
### With pytest.ini
|
||||
|
||||
```ini
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
```
|
||||
|
||||
## Basic Async Tests
|
||||
|
||||
### Simple Async Test
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
# With asyncio_mode = "auto", decorator is optional
|
||||
async def test_async_function():
|
||||
result = await async_operation()
|
||||
assert result == "success"
|
||||
|
||||
# With asyncio_mode = "strict", decorator is required
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_with_marker():
|
||||
result = await async_operation()
|
||||
assert result == "success"
|
||||
```
|
||||
|
||||
### Async Test Class
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
class TestAsyncOperations:
|
||||
async def test_fetch_data(self):
|
||||
data = await fetch_data()
|
||||
assert data is not None
|
||||
|
||||
async def test_process_data(self):
|
||||
result = await process_data({"key": "value"})
|
||||
assert result["processed"] is True
|
||||
```
|
||||
|
||||
### Testing Async Exceptions
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_exception():
|
||||
with pytest.raises(ValueError):
|
||||
await async_function_that_raises()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_exception_message():
|
||||
with pytest.raises(ValueError, match="Invalid input"):
|
||||
await validate_input("")
|
||||
```
|
||||
|
||||
## Async Fixtures
|
||||
|
||||
### Basic Async Fixture
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
@pytest.fixture
|
||||
async def async_client():
|
||||
"""Async fixture for HTTP client."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
yield client
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_call(async_client):
|
||||
response = await async_client.get("https://api.example.com/data")
|
||||
assert response.status_code == 200
|
||||
```
|
||||
|
||||
### Async Database Fixture
|
||||
|
||||
```python
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
"""Create event loop for session-scoped async fixtures."""
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def engine():
|
||||
"""Session-scoped async engine."""
|
||||
engine = create_async_engine("postgresql+asyncpg://...")
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
@pytest.fixture
|
||||
async def db_session(engine):
|
||||
"""Function-scoped database session with rollback."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async with AsyncSession(engine) as session:
|
||||
yield session
|
||||
await session.rollback()
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
```
|
||||
|
||||
### Fixture with Async Setup/Teardown
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
async def resource():
|
||||
# Async setup
|
||||
resource = await create_resource()
|
||||
await resource.initialize()
|
||||
|
||||
yield resource
|
||||
|
||||
# Async teardown
|
||||
await resource.cleanup()
|
||||
await resource.close()
|
||||
```
|
||||
|
||||
## Testing Async Generators
|
||||
|
||||
### Async Generator Function
|
||||
|
||||
```python
|
||||
async def async_data_generator():
|
||||
for i in range(5):
|
||||
await asyncio.sleep(0.1)
|
||||
yield i
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_generator():
|
||||
results = []
|
||||
async for item in async_data_generator():
|
||||
results.append(item)
|
||||
|
||||
assert results == [0, 1, 2, 3, 4]
|
||||
```
|
||||
|
||||
### Async Context Manager
|
||||
|
||||
```python
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@asynccontextmanager
|
||||
async def async_resource():
|
||||
resource = await create_resource()
|
||||
try:
|
||||
yield resource
|
||||
finally:
|
||||
await resource.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_context_manager():
|
||||
async with async_resource() as resource:
|
||||
result = await resource.operation()
|
||||
assert result is not None
|
||||
```
|
||||
|
||||
## Timeouts
|
||||
|
||||
### Test Timeout
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.timeout(5) # 5 second timeout
|
||||
async def test_slow_operation():
|
||||
result = await potentially_slow_operation()
|
||||
assert result is not None
|
||||
```
|
||||
|
||||
### Using asyncio.wait_for
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_with_timeout():
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
slow_operation(),
|
||||
timeout=2.0
|
||||
)
|
||||
assert result is not None
|
||||
except asyncio.TimeoutError:
|
||||
pytest.fail("Operation timed out")
|
||||
```
|
||||
|
||||
### Testing Timeout Behavior
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_is_raised():
|
||||
"""Verify function properly times out."""
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(
|
||||
never_completes(),
|
||||
timeout=0.1
|
||||
)
|
||||
```
|
||||
|
||||
## Concurrent Tests
|
||||
|
||||
### Testing Concurrent Operations
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_requests():
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Run 10 requests concurrently
|
||||
tasks = [
|
||||
client.get(f"https://api.example.com/items/{i}")
|
||||
for i in range(10)
|
||||
]
|
||||
responses = await asyncio.gather(*tasks)
|
||||
|
||||
assert all(r.status_code == 200 for r in responses)
|
||||
```
|
||||
|
||||
### Testing Race Conditions
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_counter_thread_safety():
|
||||
counter = AsyncCounter()
|
||||
|
||||
async def increment():
|
||||
for _ in range(100):
|
||||
await counter.increment()
|
||||
|
||||
# Run 10 concurrent incrementers
|
||||
await asyncio.gather(*[increment() for _ in range(10)])
|
||||
|
||||
assert counter.value == 1000
|
||||
```
|
||||
|
||||
## Event Loop Configuration
|
||||
|
||||
### Custom Event Loop
|
||||
|
||||
```python
|
||||
import pytest
|
||||
import asyncio
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop_policy():
|
||||
"""Use uvloop for faster async tests."""
|
||||
import uvloop
|
||||
return uvloop.EventLoopPolicy()
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop(event_loop_policy):
|
||||
asyncio.set_event_loop_policy(event_loop_policy)
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
```
|
||||
296
.agents/skills/pytest/references/fixtures.md
Normal file
296
.agents/skills/pytest/references/fixtures.md
Normal file
@@ -0,0 +1,296 @@
|
||||
# Fixtures
|
||||
|
||||
## Table of Contents
|
||||
- [Basic Fixtures](#basic-fixtures)
|
||||
- [Fixture Scopes](#fixture-scopes)
|
||||
- [Fixture Parameters](#fixture-parameters)
|
||||
- [Conftest.py](#conftestpy)
|
||||
- [Built-in Fixtures](#built-in-fixtures)
|
||||
|
||||
## Basic Fixtures
|
||||
|
||||
### Simple Fixture
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
@pytest.fixture
|
||||
def user_data():
|
||||
"""Return sample user data."""
|
||||
return {
|
||||
"name": "John Doe",
|
||||
"email": "john@example.com",
|
||||
"age": 30,
|
||||
}
|
||||
|
||||
def test_user_name(user_data):
|
||||
assert user_data["name"] == "John Doe"
|
||||
|
||||
def test_user_email(user_data):
|
||||
assert "@" in user_data["email"]
|
||||
```
|
||||
|
||||
### Fixture with Setup and Teardown
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def database():
|
||||
"""Setup database, yield, then cleanup."""
|
||||
# Setup
|
||||
db = create_database()
|
||||
db.connect()
|
||||
|
||||
yield db # Test runs here
|
||||
|
||||
# Teardown
|
||||
db.clear()
|
||||
db.disconnect()
|
||||
|
||||
def test_insert(database):
|
||||
database.insert({"id": 1, "name": "Test"})
|
||||
assert database.count() == 1
|
||||
```
|
||||
|
||||
### Fixture Returning Factory
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def make_user():
|
||||
"""Return a factory function for creating users."""
|
||||
created_users = []
|
||||
|
||||
def _make_user(name: str, email: str = None):
|
||||
user = User(name=name, email=email or f"{name}@example.com")
|
||||
created_users.append(user)
|
||||
return user
|
||||
|
||||
yield _make_user
|
||||
|
||||
# Cleanup all created users
|
||||
for user in created_users:
|
||||
user.delete()
|
||||
|
||||
def test_multiple_users(make_user):
|
||||
user1 = make_user("Alice")
|
||||
user2 = make_user("Bob")
|
||||
assert user1.name != user2.name
|
||||
```
|
||||
|
||||
## Fixture Scopes
|
||||
|
||||
```python
|
||||
# Function scope (default) - runs for each test
|
||||
@pytest.fixture(scope="function")
|
||||
def fresh_data():
|
||||
return {"count": 0}
|
||||
|
||||
# Class scope - runs once per test class
|
||||
@pytest.fixture(scope="class")
|
||||
def class_resource():
|
||||
return ExpensiveResource()
|
||||
|
||||
# Module scope - runs once per test module
|
||||
@pytest.fixture(scope="module")
|
||||
def module_connection():
|
||||
conn = create_connection()
|
||||
yield conn
|
||||
conn.close()
|
||||
|
||||
# Session scope - runs once per test session
|
||||
@pytest.fixture(scope="session")
|
||||
def session_config():
|
||||
return load_config()
|
||||
```
|
||||
|
||||
### Scope Hierarchy
|
||||
|
||||
```
|
||||
session (once per test run)
|
||||
└── package (once per test package)
|
||||
└── module (once per test file)
|
||||
└── class (once per test class)
|
||||
└── function (once per test function)
|
||||
```
|
||||
|
||||
## Fixture Parameters
|
||||
|
||||
### Parametrized Fixtures
|
||||
|
||||
```python
|
||||
@pytest.fixture(params=["sqlite", "postgresql", "mysql"])
|
||||
def database_type(request):
|
||||
"""Run tests with each database type."""
|
||||
return request.param
|
||||
|
||||
def test_connection(database_type):
|
||||
# This test runs 3 times, once for each database
|
||||
db = create_database(database_type)
|
||||
assert db.connect()
|
||||
```
|
||||
|
||||
### Fixture with IDs
|
||||
|
||||
```python
|
||||
@pytest.fixture(params=[
|
||||
pytest.param({"admin": True}, id="admin"),
|
||||
pytest.param({"admin": False}, id="regular"),
|
||||
])
|
||||
def user_config(request):
|
||||
return request.param
|
||||
|
||||
# Test output shows: test_permissions[admin], test_permissions[regular]
|
||||
```
|
||||
|
||||
### Indirect Parametrization
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def user(request):
|
||||
"""Create user based on parameter."""
|
||||
role = request.param
|
||||
return User(role=role)
|
||||
|
||||
@pytest.mark.parametrize("user", ["admin", "editor", "viewer"], indirect=True)
|
||||
def test_user_access(user):
|
||||
# user fixture receives each role as request.param
|
||||
assert user.role in ["admin", "editor", "viewer"]
|
||||
```
|
||||
|
||||
## Conftest.py
|
||||
|
||||
### tests/conftest.py
|
||||
|
||||
```python
|
||||
"""Shared fixtures for all tests."""
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# Session-scoped database engine
|
||||
@pytest.fixture(scope="session")
|
||||
def engine():
|
||||
return create_engine("sqlite:///:memory:")
|
||||
|
||||
# Function-scoped session with transaction rollback
|
||||
@pytest.fixture(scope="function")
|
||||
def db_session(engine):
|
||||
connection = engine.connect()
|
||||
transaction = connection.begin()
|
||||
Session = sessionmaker(bind=connection)
|
||||
session = Session()
|
||||
|
||||
yield session
|
||||
|
||||
session.close()
|
||||
transaction.rollback()
|
||||
connection.close()
|
||||
|
||||
# Shared test data
|
||||
@pytest.fixture
|
||||
def sample_products():
|
||||
return [
|
||||
{"name": "Widget", "price": 9.99},
|
||||
{"name": "Gadget", "price": 19.99},
|
||||
{"name": "Gizmo", "price": 29.99},
|
||||
]
|
||||
```
|
||||
|
||||
### Nested Conftest Files
|
||||
|
||||
```
|
||||
tests/
|
||||
├── conftest.py # Available to all tests
|
||||
├── unit/
|
||||
│ ├── conftest.py # Available to unit tests only
|
||||
│ └── test_models.py
|
||||
└── integration/
|
||||
├── conftest.py # Available to integration tests only
|
||||
└── test_api.py
|
||||
```
|
||||
|
||||
## Built-in Fixtures
|
||||
|
||||
### tmp_path / tmp_path_factory
|
||||
|
||||
```python
|
||||
def test_create_file(tmp_path):
|
||||
"""tmp_path provides unique temporary directory."""
|
||||
file = tmp_path / "test.txt"
|
||||
file.write_text("Hello, World!")
|
||||
assert file.read_text() == "Hello, World!"
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def session_temp_dir(tmp_path_factory):
|
||||
"""Create temp dir for entire session."""
|
||||
return tmp_path_factory.mktemp("session_data")
|
||||
```
|
||||
|
||||
### capsys / capfd
|
||||
|
||||
```python
|
||||
def test_print_output(capsys):
|
||||
"""Capture stdout/stderr."""
|
||||
print("Hello")
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == "Hello\n"
|
||||
|
||||
def test_file_descriptor_output(capfd):
|
||||
"""Capture at file descriptor level."""
|
||||
import os
|
||||
os.system("echo 'Hello'")
|
||||
captured = capfd.readouterr()
|
||||
assert "Hello" in captured.out
|
||||
```
|
||||
|
||||
### monkeypatch
|
||||
|
||||
```python
|
||||
def test_env_variable(monkeypatch):
|
||||
"""Modify environment temporarily."""
|
||||
monkeypatch.setenv("API_KEY", "test-key")
|
||||
assert os.environ["API_KEY"] == "test-key"
|
||||
|
||||
def test_module_attribute(monkeypatch):
|
||||
"""Modify module attribute temporarily."""
|
||||
monkeypatch.setattr("module.CONFIG", {"debug": True})
|
||||
assert module.CONFIG["debug"] is True
|
||||
|
||||
def test_dict_item(monkeypatch):
|
||||
"""Modify dictionary temporarily."""
|
||||
monkeypatch.setitem(app.settings, "DEBUG", True)
|
||||
```
|
||||
|
||||
### request
|
||||
|
||||
```python
|
||||
@pytest.fixture
|
||||
def resource(request):
|
||||
"""Access test context."""
|
||||
print(f"Running: {request.node.name}")
|
||||
print(f"Module: {request.module.__name__}")
|
||||
print(f"Function: {request.function.__name__}")
|
||||
|
||||
# Access fixture parameters
|
||||
if hasattr(request, "param"):
|
||||
return create_resource(request.param)
|
||||
|
||||
return create_default_resource()
|
||||
```
|
||||
|
||||
### Autouse Fixtures
|
||||
|
||||
```python
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_logging():
|
||||
"""Automatically runs for every test."""
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
yield
|
||||
logging.shutdown()
|
||||
|
||||
@pytest.fixture(autouse=True, scope="session")
|
||||
def global_setup():
|
||||
"""Run once at session start."""
|
||||
initialize_system()
|
||||
yield
|
||||
cleanup_system()
|
||||
```
|
||||
354
.agents/skills/pytest/references/mocking.md
Normal file
354
.agents/skills/pytest/references/mocking.md
Normal file
@@ -0,0 +1,354 @@
|
||||
# Mocking
|
||||
|
||||
## Table of Contents
|
||||
- [Basic Mocking](#basic-mocking)
|
||||
- [Patching](#patching)
|
||||
- [Mock Objects](#mock-objects)
|
||||
- [Async Mocking](#async-mocking)
|
||||
- [Fixture-Based Mocking](#fixture-based-mocking)
|
||||
- [Common Patterns](#common-patterns)
|
||||
|
||||
## Basic Mocking
|
||||
|
||||
### MagicMock Basics
|
||||
|
||||
```python
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
def test_with_mock():
|
||||
# Create a mock object
|
||||
mock_service = MagicMock()
|
||||
|
||||
# Configure return value
|
||||
mock_service.get_data.return_value = {"id": 1, "name": "Test"}
|
||||
|
||||
# Use mock
|
||||
result = mock_service.get_data()
|
||||
assert result["name"] == "Test"
|
||||
|
||||
# Verify call
|
||||
mock_service.get_data.assert_called_once()
|
||||
```
|
||||
|
||||
### Return Value Configuration
|
||||
|
||||
```python
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
mock = MagicMock()
|
||||
|
||||
# Simple return value
|
||||
mock.method.return_value = 42
|
||||
|
||||
# Different returns on consecutive calls
|
||||
mock.method.side_effect = [1, 2, 3]
|
||||
assert mock.method() == 1
|
||||
assert mock.method() == 2
|
||||
assert mock.method() == 3
|
||||
|
||||
# Raise exception
|
||||
mock.method.side_effect = ValueError("Error!")
|
||||
|
||||
# Dynamic return value
|
||||
mock.method.side_effect = lambda x: x * 2
|
||||
assert mock.method(5) == 10
|
||||
```
|
||||
|
||||
## Patching
|
||||
|
||||
### patch Decorator
|
||||
|
||||
```python
|
||||
from unittest.mock import patch
|
||||
|
||||
# Patch a function
|
||||
@patch("mymodule.external_api")
|
||||
def test_with_patched_api(mock_api):
|
||||
mock_api.return_value = {"status": "ok"}
|
||||
result = mymodule.call_api()
|
||||
assert result["status"] == "ok"
|
||||
|
||||
# Patch multiple
|
||||
@patch("mymodule.function_a")
|
||||
@patch("mymodule.function_b")
|
||||
def test_multiple_patches(mock_b, mock_a):
|
||||
# Note: decorators apply bottom-up
|
||||
mock_a.return_value = "a"
|
||||
mock_b.return_value = "b"
|
||||
```
|
||||
|
||||
### patch Context Manager
|
||||
|
||||
```python
|
||||
from unittest.mock import patch
|
||||
|
||||
def test_with_context_manager():
|
||||
with patch("mymodule.external_api") as mock_api:
|
||||
mock_api.return_value = {"data": "mocked"}
|
||||
result = mymodule.process()
|
||||
assert result == {"data": "mocked"}
|
||||
# Original function restored after with block
|
||||
```
|
||||
|
||||
### patch.object
|
||||
|
||||
```python
|
||||
from unittest.mock import patch
|
||||
|
||||
class MyService:
|
||||
def fetch_data(self):
|
||||
return "real data"
|
||||
|
||||
def test_patch_instance_method():
|
||||
service = MyService()
|
||||
|
||||
with patch.object(service, "fetch_data", return_value="mocked"):
|
||||
assert service.fetch_data() == "mocked"
|
||||
```
|
||||
|
||||
### Patching Where Used
|
||||
|
||||
```python
|
||||
# mymodule.py
|
||||
from requests import get # 'get' is imported here
|
||||
|
||||
def fetch_url(url):
|
||||
return get(url).text
|
||||
|
||||
# test_mymodule.py
|
||||
# Patch where it's USED, not where it's DEFINED
|
||||
@patch("mymodule.get") # NOT "requests.get"
|
||||
def test_fetch_url(mock_get):
|
||||
mock_get.return_value.text = "mocked response"
|
||||
result = fetch_url("http://example.com")
|
||||
assert result == "mocked response"
|
||||
```
|
||||
|
||||
## Mock Objects
|
||||
|
||||
### Spec and Autospec
|
||||
|
||||
```python
|
||||
from unittest.mock import MagicMock, create_autospec
|
||||
|
||||
class UserService:
|
||||
def get_user(self, user_id: int) -> dict:
|
||||
pass
|
||||
|
||||
def create_user(self, data: dict) -> dict:
|
||||
pass
|
||||
|
||||
# Basic mock (allows any attribute)
|
||||
mock = MagicMock()
|
||||
mock.nonexistent_method() # Works, but shouldn't
|
||||
|
||||
# Spec mock (restricts to real attributes)
|
||||
mock = MagicMock(spec=UserService)
|
||||
# mock.nonexistent_method() # Raises AttributeError
|
||||
|
||||
# Autospec (also checks signatures)
|
||||
mock = create_autospec(UserService)
|
||||
# mock.get_user() # Raises TypeError (missing user_id)
|
||||
mock.get_user(123) # Works
|
||||
```
|
||||
|
||||
### Assertion Methods
|
||||
|
||||
```python
|
||||
from unittest.mock import MagicMock, call
|
||||
|
||||
mock = MagicMock()
|
||||
mock.method(1, 2, key="value")
|
||||
mock.method(3, 4)
|
||||
|
||||
# Verify calls
|
||||
mock.method.assert_called()
|
||||
mock.method.assert_called_once() # Fails - called twice
|
||||
mock.method.assert_called_with(3, 4)
|
||||
mock.method.assert_any_call(1, 2, key="value")
|
||||
|
||||
# Check call count
|
||||
assert mock.method.call_count == 2
|
||||
|
||||
# Check all calls
|
||||
assert mock.method.call_args_list == [
|
||||
call(1, 2, key="value"),
|
||||
call(3, 4),
|
||||
]
|
||||
|
||||
# Reset mock
|
||||
mock.reset_mock()
|
||||
assert mock.method.call_count == 0
|
||||
```
|
||||
|
||||
## Async Mocking
|
||||
|
||||
### AsyncMock
|
||||
|
||||
```python
|
||||
from unittest.mock import AsyncMock, patch
|
||||
import pytest
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_mock():
|
||||
mock = AsyncMock(return_value={"data": "mocked"})
|
||||
result = await mock()
|
||||
assert result["data"] == "mocked"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("mymodule.async_fetch", new_callable=AsyncMock)
|
||||
async def test_patched_async(mock_fetch):
|
||||
mock_fetch.return_value = {"status": "ok"}
|
||||
result = await mymodule.process()
|
||||
assert result["status"] == "ok"
|
||||
```
|
||||
|
||||
### Async Side Effects
|
||||
|
||||
```python
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_side_effect():
|
||||
mock = AsyncMock()
|
||||
|
||||
# Return different values
|
||||
mock.side_effect = [1, 2, 3]
|
||||
assert await mock() == 1
|
||||
assert await mock() == 2
|
||||
|
||||
# Async function as side effect
|
||||
async def async_side_effect(x):
|
||||
return x * 2
|
||||
|
||||
mock.side_effect = async_side_effect
|
||||
assert await mock(5) == 10
|
||||
```
|
||||
|
||||
## Fixture-Based Mocking
|
||||
|
||||
### Mock Fixtures
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@pytest.fixture
|
||||
def mock_database():
|
||||
"""Fixture providing mock database."""
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value = [{"id": 1}, {"id": 2}]
|
||||
return mock_db
|
||||
|
||||
def test_with_mock_fixture(mock_database):
|
||||
result = mock_database.query("SELECT * FROM users")
|
||||
assert len(result) == 2
|
||||
|
||||
@pytest.fixture
|
||||
def mock_external_api():
|
||||
"""Fixture with patching."""
|
||||
with patch("mymodule.external_api") as mock:
|
||||
mock.return_value = {"status": "ok"}
|
||||
yield mock
|
||||
```
|
||||
|
||||
### Monkeypatch Fixture
|
||||
|
||||
```python
|
||||
def test_with_monkeypatch(monkeypatch):
|
||||
# Patch function
|
||||
monkeypatch.setattr("mymodule.get_config", lambda: {"debug": True})
|
||||
|
||||
# Patch environment variable
|
||||
monkeypatch.setenv("API_KEY", "test-key")
|
||||
|
||||
# Patch dictionary item
|
||||
monkeypatch.setitem(mymodule.settings, "DEBUG", True)
|
||||
|
||||
# Patch attribute
|
||||
monkeypatch.setattr(mymodule.client, "timeout", 5)
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Mock HTTP Responses
|
||||
|
||||
```python
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
@patch("requests.get")
|
||||
def test_http_request(mock_get):
|
||||
# Configure mock response
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"data": "test"}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
result = fetch_data("http://api.example.com")
|
||||
|
||||
assert result == {"data": "test"}
|
||||
mock_get.assert_called_once_with("http://api.example.com")
|
||||
```
|
||||
|
||||
### Mock File Operations
|
||||
|
||||
```python
|
||||
from unittest.mock import mock_open, patch
|
||||
|
||||
def test_read_file():
|
||||
mock_file_content = "Hello, World!"
|
||||
|
||||
with patch("builtins.open", mock_open(read_data=mock_file_content)):
|
||||
result = read_file("test.txt")
|
||||
assert result == "Hello, World!"
|
||||
|
||||
def test_write_file():
|
||||
m = mock_open()
|
||||
|
||||
with patch("builtins.open", m):
|
||||
write_file("test.txt", "content")
|
||||
|
||||
m().write.assert_called_once_with("content")
|
||||
```
|
||||
|
||||
### Mock Datetime
|
||||
|
||||
```python
|
||||
from unittest.mock import patch
|
||||
from datetime import datetime
|
||||
|
||||
@patch("mymodule.datetime")
|
||||
def test_with_frozen_time(mock_datetime):
|
||||
mock_datetime.now.return_value = datetime(2024, 1, 15, 12, 0, 0)
|
||||
mock_datetime.side_effect = lambda *args, **kwargs: datetime(*args, **kwargs)
|
||||
|
||||
result = mymodule.get_timestamp()
|
||||
assert result == "2024-01-15 12:00:00"
|
||||
```
|
||||
|
||||
### Mock Class Instance
|
||||
|
||||
```python
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
class EmailService:
|
||||
def send(self, to, subject, body):
|
||||
# Real implementation
|
||||
pass
|
||||
|
||||
@patch("mymodule.EmailService")
|
||||
def test_email_sent(MockEmailService):
|
||||
# Configure mock instance
|
||||
mock_instance = MagicMock()
|
||||
MockEmailService.return_value = mock_instance
|
||||
|
||||
# Call function that uses EmailService
|
||||
send_notification("user@example.com", "Hello!")
|
||||
|
||||
# Verify email was sent
|
||||
mock_instance.send.assert_called_once_with(
|
||||
"user@example.com",
|
||||
"Notification",
|
||||
"Hello!",
|
||||
)
|
||||
```
|
||||
399
.agents/skills/write-unit-tests/SKILL.md
Normal file
399
.agents/skills/write-unit-tests/SKILL.md
Normal file
@@ -0,0 +1,399 @@
|
||||
---
|
||||
name: write-unit-tests
|
||||
description: Writing unit and integration tests for the tldraw SDK. Use when creating new tests, adding test coverage, or fixing failing tests in packages/editor or packages/tldraw. Covers Vitest patterns, TestEditor usage, and test file organization.
|
||||
---
|
||||
|
||||
# Writing tests
|
||||
|
||||
Unit and integration tests use Vitest. Tests run from workspace directories, not the repo root.
|
||||
|
||||
## Test file locations
|
||||
|
||||
**Unit tests** - alongside source files:
|
||||
|
||||
```
|
||||
packages/editor/src/lib/primitives/Vec.ts
|
||||
packages/editor/src/lib/primitives/Vec.test.ts # Same directory
|
||||
```
|
||||
|
||||
**Integration tests** - in `src/test/` directory:
|
||||
|
||||
```
|
||||
packages/tldraw/src/test/SelectTool.test.ts
|
||||
packages/tldraw/src/test/commands/createShape.test.ts
|
||||
```
|
||||
|
||||
**Shape/tool tests** - alongside the implementation:
|
||||
|
||||
```
|
||||
packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.test.ts
|
||||
packages/tldraw/src/lib/shapes/arrow/ArrowShapeTool.test.ts
|
||||
```
|
||||
|
||||
## Which workspace to test in
|
||||
|
||||
- **packages/editor**: Core primitives, geometry, managers, base editor functionality
|
||||
- **packages/tldraw**: Anything needing default shapes/tools (most integration tests)
|
||||
|
||||
```bash
|
||||
cd packages/tldraw && yarn test run
|
||||
cd packages/tldraw && yarn test run --grep "SelectTool"
|
||||
```
|
||||
|
||||
## TestEditor vs Editor
|
||||
|
||||
Use `TestEditor` for integration tests (includes default shapes/tools):
|
||||
|
||||
```typescript
|
||||
import { createShapeId } from '@tldraw/editor'
|
||||
import { TestEditor } from './TestEditor'
|
||||
|
||||
let editor: TestEditor
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new TestEditor()
|
||||
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
editor?.dispose()
|
||||
})
|
||||
```
|
||||
|
||||
Use raw `Editor` when testing editor setup or custom configurations:
|
||||
|
||||
```typescript
|
||||
import { Editor, createTLStore } from '@tldraw/editor'
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new Editor({
|
||||
shapeUtils: [CustomShape],
|
||||
bindingUtils: [],
|
||||
tools: [CustomTool],
|
||||
store: createTLStore({ shapeUtils: [CustomShape], bindingUtils: [] }),
|
||||
getContainer: () => document.body,
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Common TestEditor methods
|
||||
|
||||
```typescript
|
||||
// Pointer simulation
|
||||
editor.pointerDown(x, y, options?)
|
||||
editor.pointerMove(x, y, options?)
|
||||
editor.pointerUp(x, y, options?)
|
||||
editor.click(x, y, shapeId?)
|
||||
editor.doubleClick(x, y, shapeId?)
|
||||
|
||||
// Keyboard simulation
|
||||
editor.keyDown(key, options?)
|
||||
editor.keyUp(key, options?)
|
||||
|
||||
// State assertions
|
||||
editor.expectToBeIn('select.idle')
|
||||
editor.expectToBeIn('select.crop.idle')
|
||||
|
||||
// Shape assertions
|
||||
editor.expectShapeToMatch({ id, x, y, props: { ... } })
|
||||
|
||||
// Shape operations
|
||||
editor.createShapes([{ id, type, x, y, props }])
|
||||
editor.updateShapes([{ id, type, props }])
|
||||
editor.getShape(id)
|
||||
editor.select(id1, id2)
|
||||
editor.selectAll()
|
||||
editor.selectNone()
|
||||
editor.getSelectedShapeIds()
|
||||
editor.getOnlySelectedShape()
|
||||
|
||||
// Tool operations
|
||||
editor.setCurrentTool('arrow')
|
||||
editor.getCurrentToolId()
|
||||
|
||||
// Undo/redo
|
||||
editor.undo()
|
||||
editor.redo()
|
||||
```
|
||||
|
||||
## Pointer event options
|
||||
|
||||
```typescript
|
||||
editor.pointerDown(100, 100, {
|
||||
target: 'shape', // 'canvas' | 'shape' | 'handle' | 'selection'
|
||||
shape: editor.getShape(id),
|
||||
})
|
||||
|
||||
editor.pointerDown(150, 300, {
|
||||
target: 'selection',
|
||||
handle: 'bottom', // 'top' | 'bottom' | 'left' | 'right' | corners
|
||||
})
|
||||
|
||||
editor.doubleClick(550, 550, {
|
||||
target: 'selection',
|
||||
handle: 'bottom_right',
|
||||
})
|
||||
```
|
||||
|
||||
## Setup patterns
|
||||
|
||||
### Standard setup with shape IDs
|
||||
|
||||
```typescript
|
||||
const ids = {
|
||||
box1: createShapeId('box1'),
|
||||
box2: createShapeId('box2'),
|
||||
arrow1: createShapeId('arrow1'),
|
||||
}
|
||||
|
||||
vi.useFakeTimers()
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new TestEditor()
|
||||
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
|
||||
editor.createShapes([
|
||||
{ id: ids.box1, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } },
|
||||
{ id: ids.box2, type: 'geo', x: 300, y: 300, props: { w: 100, h: 100 } },
|
||||
])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
editor?.dispose()
|
||||
})
|
||||
```
|
||||
|
||||
### Reusable props
|
||||
|
||||
```typescript
|
||||
const imageProps = {
|
||||
assetId: null,
|
||||
playing: true,
|
||||
url: '',
|
||||
w: 1200,
|
||||
h: 800,
|
||||
}
|
||||
|
||||
editor.createShapes([
|
||||
{ id: ids.imageA, type: 'image', x: 100, y: 100, props: imageProps },
|
||||
{ id: ids.imageB, type: 'image', x: 500, y: 500, props: { ...imageProps, w: 600, h: 400 } },
|
||||
])
|
||||
```
|
||||
|
||||
### Helper functions
|
||||
|
||||
```typescript
|
||||
function arrow(id = ids.arrow1) {
|
||||
return editor.getShape(id) as TLArrowShape
|
||||
}
|
||||
|
||||
function bindings(id = ids.arrow1) {
|
||||
return getArrowBindings(editor, arrow(id))
|
||||
}
|
||||
```
|
||||
|
||||
## Mocking with vi.spyOn
|
||||
|
||||
```typescript
|
||||
// Mock return value
|
||||
vi.spyOn(editor, 'getIsReadonly').mockReturnValue(true)
|
||||
|
||||
// Mock implementation
|
||||
const isHiddenSpy = vi.spyOn(editor, 'isShapeHidden')
|
||||
isHiddenSpy.mockImplementation((shape) => shape.id === ids.hiddenShape)
|
||||
|
||||
// Verify calls
|
||||
const spy = vi.spyOn(editor, 'setSelectedShapes')
|
||||
editor.selectAll()
|
||||
expect(spy).toHaveBeenCalled()
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
|
||||
// Always restore
|
||||
isHiddenSpy.mockRestore()
|
||||
```
|
||||
|
||||
## Fake timers
|
||||
|
||||
```typescript
|
||||
vi.useFakeTimers()
|
||||
|
||||
// Mock animation frame
|
||||
window.requestAnimationFrame = (cb) => setTimeout(cb, 1000 / 60)
|
||||
window.cancelAnimationFrame = (id) => clearTimeout(id)
|
||||
|
||||
it('handles animation', () => {
|
||||
editor.alignShapes(editor.getSelectedShapeIds(), 'right')
|
||||
vi.advanceTimersByTime(1000)
|
||||
// Assert after animation completes
|
||||
})
|
||||
```
|
||||
|
||||
## Assertions
|
||||
|
||||
### Shape matching
|
||||
|
||||
```typescript
|
||||
// Partial matching (most common)
|
||||
expect(editor.getShape(id)).toMatchObject({
|
||||
type: 'geo',
|
||||
x: 100,
|
||||
props: { w: 100 },
|
||||
})
|
||||
|
||||
editor.expectShapeToMatch({
|
||||
id: ids.box1,
|
||||
x: 350,
|
||||
y: 350,
|
||||
})
|
||||
|
||||
// Floating point matching (custom matcher)
|
||||
expect(result).toCloselyMatchObject({
|
||||
props: { normalizedAnchor: { x: 0.5, y: 0.75 } },
|
||||
})
|
||||
```
|
||||
|
||||
### Array assertions
|
||||
|
||||
```typescript
|
||||
expect(editor.getSelectedShapeIds()).toMatchObject([ids.box1])
|
||||
expect(Array.from(selectedIds).sort()).toEqual([id1, id2, id3].sort())
|
||||
expect(shapes).toContain('geo')
|
||||
expect(shapes).not.toContain(ids.lockedShape)
|
||||
```
|
||||
|
||||
### State assertions
|
||||
|
||||
```typescript
|
||||
editor.expectToBeIn('select.idle')
|
||||
editor.expectToBeIn('select.brushing')
|
||||
editor.expectToBeIn('select.crop.idle')
|
||||
```
|
||||
|
||||
## Testing undo/redo
|
||||
|
||||
```typescript
|
||||
it('handles undo/redo', () => {
|
||||
editor.doubleClick(550, 550, ids.image)
|
||||
editor.expectToBeIn('select.crop.idle')
|
||||
|
||||
editor.updateShape({ id: ids.image, type: 'image', props: { crop: newCrop } })
|
||||
|
||||
editor.undo()
|
||||
editor.expectToBeIn('select.crop.idle')
|
||||
expect(editor.getShape(ids.image)!.props.crop).toMatchObject(originalCrop)
|
||||
|
||||
editor.redo()
|
||||
expect(editor.getShape(ids.image)!.props.crop).toMatchObject(newCrop)
|
||||
})
|
||||
```
|
||||
|
||||
## Testing TypeScript types
|
||||
|
||||
```typescript
|
||||
it('Uses typescript generics', () => {
|
||||
expect(() => {
|
||||
// @ts-expect-error - wrong props type
|
||||
editor.createShape({ id, type: 'geo', props: { w: 'OH NO' } })
|
||||
|
||||
// @ts-expect-error - unknown prop
|
||||
editor.createShape({ id, type: 'geo', props: { foo: 'bar' } })
|
||||
|
||||
// Valid
|
||||
editor.createShape<TLGeoShape>({ id, type: 'geo', props: { w: 100 } })
|
||||
}).toThrow()
|
||||
})
|
||||
```
|
||||
|
||||
## Testing custom shapes
|
||||
|
||||
```typescript
|
||||
declare module '@tldraw/tlschema' {
|
||||
export interface TLGlobalShapePropsMap {
|
||||
'my-custom-shape': { w: number; h: number; text: string | undefined }
|
||||
}
|
||||
}
|
||||
|
||||
class CustomShape extends ShapeUtil<ICustomShape> {
|
||||
static override type = 'my-custom-shape'
|
||||
static override props: RecordProps<ICustomShape> = {
|
||||
w: T.number,
|
||||
h: T.number,
|
||||
text: T.string.optional(),
|
||||
}
|
||||
getDefaultProps() {
|
||||
return { w: 200, h: 200, text: '' }
|
||||
}
|
||||
getGeometry(shape) {
|
||||
return new Rectangle2d({ width: shape.props.w, height: shape.props.h })
|
||||
}
|
||||
indicator() {}
|
||||
component() {}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing side effects
|
||||
|
||||
```typescript
|
||||
beforeEach(() => {
|
||||
editor = new TestEditor()
|
||||
editor.sideEffects.registerAfterChangeHandler('instance_page_state', (prev, next) => {
|
||||
if (prev.croppingShapeId !== next.croppingShapeId) {
|
||||
// Handle state change
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Testing events
|
||||
|
||||
```typescript
|
||||
it('emits wheel events', () => {
|
||||
const handler = vi.fn()
|
||||
editor.on('event', handler)
|
||||
|
||||
editor.dispatch({
|
||||
type: 'wheel',
|
||||
name: 'wheel',
|
||||
delta: { x: 0, y: 10, z: 0 },
|
||||
point: { x: 100, y: 100, z: 1 },
|
||||
shiftKey: false,
|
||||
// ... other modifiers
|
||||
})
|
||||
editor.emit('tick', 16) // Flush batched events
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ name: 'wheel' }))
|
||||
})
|
||||
```
|
||||
|
||||
## Method chaining
|
||||
|
||||
```typescript
|
||||
editor
|
||||
.expectToBeIn('select.idle')
|
||||
.select(ids.imageA, ids.imageB)
|
||||
.doubleClick(550, 550, { target: 'selection', handle: 'bottom_right' })
|
||||
.expectToBeIn('select.idle')
|
||||
|
||||
editor.setCurrentTool('arrow').pointerDown(0, 0).pointerMove(100, 100).pointerUp()
|
||||
```
|
||||
|
||||
## Running tests
|
||||
|
||||
```bash
|
||||
cd packages/tldraw && yarn test run
|
||||
cd packages/tldraw && yarn test run --grep "arrow"
|
||||
cd packages/editor && yarn test run --grep "Vec"
|
||||
|
||||
# Watch mode
|
||||
cd packages/tldraw && yarn test
|
||||
```
|
||||
|
||||
## Key patterns summary
|
||||
|
||||
- Use `createShapeId()` for shape IDs
|
||||
- Use `vi.useFakeTimers()` for time-dependent behavior
|
||||
- Clear shapes in `beforeEach`, dispose in `afterEach`
|
||||
- Test in `packages/tldraw` for shapes/tools
|
||||
- Use `expectToBeIn()` for state machine assertions
|
||||
- Use `toMatchObject()` for partial matching
|
||||
- Use `toCloselyMatchObject()` for floating point values
|
||||
- Mock with `vi.spyOn()` and always `mockRestore()`
|
||||
1
.claude/skills/api-documentation-generator
Symbolic link
1
.claude/skills/api-documentation-generator
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/api-documentation-generator
|
||||
1
.claude/skills/pytest
Symbolic link
1
.claude/skills/pytest
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/pytest
|
||||
@@ -1,7 +1,7 @@
|
||||
name: "K8S Fission Deployment"
|
||||
on:
|
||||
push:
|
||||
branches: [ 'main' ]
|
||||
branches: ["main"]
|
||||
jobs:
|
||||
deployment-fission:
|
||||
name: Deployment fission functions
|
||||
@@ -14,6 +14,21 @@ jobs:
|
||||
steps:
|
||||
- name: ☸️ Setup kubectl
|
||||
uses: azure/setup-kubectl@v4
|
||||
|
||||
- name: 🐍 Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: "pip"
|
||||
|
||||
- name: 📦 Install dependencies and run tests
|
||||
working-directory: ./apps
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pytest pytest-mock
|
||||
pytest tests/ -v --tb=short
|
||||
|
||||
- name: 🔄 Cache
|
||||
id: cache
|
||||
uses: actions/cache@v4
|
||||
|
||||
@@ -18,6 +18,21 @@ jobs:
|
||||
run: echo "K8S_PROFILE=`echo ${GITHUB_REF_NAME:-${GITHUB_REF#refs/heads/}} | tr '[:lower:]' '[:upper:]'`" >> $GITHUB_ENV
|
||||
- name: ☸️ Setup kubectl
|
||||
uses: azure/setup-kubectl@v4
|
||||
|
||||
- name: 🐍 Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
|
||||
- name: 📦 Install dependencies and run tests
|
||||
working-directory: ./apps
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pytest pytest-mock
|
||||
pytest tests/ -v --tb=short
|
||||
|
||||
- name: 🛠️ Configure Kubeconfig
|
||||
uses: azure/k8s-set-context@v4
|
||||
with:
|
||||
|
||||
@@ -44,10 +44,27 @@ def main():
|
||||
|
||||
|
||||
def make_insert_request():
|
||||
"""
|
||||
Handle POST request to create a new AI user.
|
||||
|
||||
Validates the request body using AiUserCreate schema, inserts a new record
|
||||
into the public.ai_user table, and returns the created user data.
|
||||
|
||||
Returns:
|
||||
tuple: (json_response, status_code, headers)
|
||||
- 201: User created successfully
|
||||
- 400: Validation error in request body
|
||||
- 409: Duplicate entry violation
|
||||
- 500: Internal server error
|
||||
"""
|
||||
try:
|
||||
body = AiUserCreate(**(request.get_json(silent=True) or {}))
|
||||
except ValidationError as e:
|
||||
return jsonify({"errorCode": "VALIDATION_ERROR", "details": e.errors()}), 400, CORS_HEADERS
|
||||
return (
|
||||
jsonify({"errorCode": "VALIDATION_ERROR", "details": e.errors()}),
|
||||
400,
|
||||
CORS_HEADERS,
|
||||
)
|
||||
|
||||
sql = """
|
||||
INSERT INTO public.ai_user (id, name, dob, email, gender)
|
||||
@@ -59,13 +76,19 @@ def make_insert_request():
|
||||
conn = init_db_connection()
|
||||
with conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql, (str(uuid.uuid4()), body.name,
|
||||
body.dob, body.email, body.gender))
|
||||
cur.execute(
|
||||
sql,
|
||||
(str(uuid.uuid4()), body.name, body.dob, body.email, body.gender),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return jsonify(db_row_to_dict(cur, row)), 201, CORS_HEADERS
|
||||
except IntegrityError as e:
|
||||
# vi phạm unique(tag,kind,ref)
|
||||
return jsonify({"errorCode": "DUPLICATE_TAG", "details": str(e)}), 409, CORS_HEADERS
|
||||
return (
|
||||
jsonify({"errorCode": "DUPLICATE_TAG", "details": str(e)}),
|
||||
409,
|
||||
CORS_HEADERS,
|
||||
)
|
||||
except Exception as err:
|
||||
return jsonify({"error": str(err)}), 500, CORS_HEADERS
|
||||
finally:
|
||||
@@ -74,6 +97,17 @@ def make_insert_request():
|
||||
|
||||
|
||||
def make_filter_request():
|
||||
"""
|
||||
Handle GET request to filter and list AI users.
|
||||
|
||||
Retrieves pagination parameters from request queries, executes the filter
|
||||
query against the database, and returns a list of matching users.
|
||||
|
||||
Returns:
|
||||
tuple: (json_response, status_code, headers)
|
||||
- 200: Successfully retrieved users list
|
||||
- 500: Internal server error
|
||||
"""
|
||||
paging = UserPage.from_request_queries()
|
||||
|
||||
conn = None
|
||||
@@ -89,6 +123,19 @@ def make_filter_request():
|
||||
|
||||
|
||||
def __filter_users(cursor, paging: "UserPage"):
|
||||
"""
|
||||
Build and execute SQL query to filter users based on pagination and filter criteria.
|
||||
|
||||
Constructs dynamic WHERE clause from UserFilter attributes, applies sorting
|
||||
and pagination, and returns matching database records.
|
||||
|
||||
Args:
|
||||
cursor: Database cursor for executing queries.
|
||||
paging: UserPage object containing pagination and filter parameters.
|
||||
|
||||
Returns:
|
||||
list: List of user records matching the filter criteria.
|
||||
"""
|
||||
conditions = []
|
||||
values = {}
|
||||
|
||||
@@ -170,6 +217,15 @@ class Page:
|
||||
|
||||
@classmethod
|
||||
def from_request_queries(cls) -> "Page":
|
||||
"""
|
||||
Create Page instance from HTTP request query parameters.
|
||||
|
||||
Extracts 'page', 'size', and 'asc' parameters from the request URL.
|
||||
Defaults: page=0, size=8, asc=None.
|
||||
|
||||
Returns:
|
||||
Page: A new Page instance with values from query parameters.
|
||||
"""
|
||||
paging = Page()
|
||||
paging.page = int(request.args.get("page", 0))
|
||||
paging.size = int(request.args.get("size", 8))
|
||||
@@ -193,6 +249,15 @@ class UserFilter:
|
||||
|
||||
@classmethod
|
||||
def from_request_queries(cls) -> "UserFilter":
|
||||
"""
|
||||
Create UserFilter instance from HTTP request query parameters.
|
||||
|
||||
Extracts filter parameters with 'filter[...]' prefix from the request URL.
|
||||
Supports filtering by ids, keyword, name, email, gender, date ranges.
|
||||
|
||||
Returns:
|
||||
UserFilter: A new UserFilter instance with values from query parameters.
|
||||
"""
|
||||
filter = UserFilter()
|
||||
filter.ids = request.args.getlist("filter[ids]")
|
||||
filter.keyword = request.args.get("filter[keyword]")
|
||||
@@ -222,6 +287,15 @@ class UserPage(Page):
|
||||
|
||||
@classmethod
|
||||
def from_request_queries(cls) -> "UserPage":
|
||||
"""
|
||||
Create UserPage instance from HTTP request query parameters.
|
||||
|
||||
Combines pagination (page, size, asc) and filter parameters from the request URL.
|
||||
Also parses 'sortby' parameter to UserSortField enum.
|
||||
|
||||
Returns:
|
||||
UserPage: A new UserPage instance with all query parameters.
|
||||
"""
|
||||
base = super(UserPage, cls).from_request_queries()
|
||||
paging = UserPage(**dataclasses.asdict(base))
|
||||
|
||||
|
||||
@@ -2,3 +2,5 @@ psycopg2-binary==2.9.10
|
||||
pydantic==2.11.7
|
||||
PyNaCl==1.6.0
|
||||
Flask==3.1.0
|
||||
pytest==8.3.4
|
||||
pytest-mock==3.14.0
|
||||
|
||||
0
apps/tests/__init__.py
Normal file
0
apps/tests/__init__.py
Normal file
56
apps/tests/conftest.py
Normal file
56
apps/tests/conftest.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from flask import Flask
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create Flask test app"""
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Create Flask test client"""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db_connection():
|
||||
"""Mock database connection"""
|
||||
mock_conn = MagicMock()
|
||||
mock_cursor = MagicMock()
|
||||
|
||||
mock_conn.__enter__ = MagicMock(return_value=mock_conn)
|
||||
mock_conn.__exit__ = MagicMock(return_value=False)
|
||||
mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
|
||||
mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
return mock_conn, mock_cursor
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_user_data():
|
||||
"""Sample user data for testing"""
|
||||
return {
|
||||
"name": "John Doe",
|
||||
"email": "john@example.com",
|
||||
"dob": "1990-01-01",
|
||||
"gender": "male"
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_filter_params():
|
||||
"""Sample filter parameters for testing"""
|
||||
return {
|
||||
"page": 0,
|
||||
"size": 8,
|
||||
"asc": "false",
|
||||
"filter[keyword]": "test",
|
||||
"filter[name]": "John",
|
||||
"filter[email]": "john@example.com",
|
||||
"filter[gender]": "male",
|
||||
}
|
||||
357
apps/tests/test_filter_insert.py
Normal file
357
apps/tests/test_filter_insert.py
Normal file
@@ -0,0 +1,357 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class TestMain:
|
||||
"""Test cases for the main() function"""
|
||||
|
||||
@patch("filter_insert.make_filter_request")
|
||||
def test_main_get_method(self, mock_filter_request):
|
||||
"""Test main() with GET method calls make_filter_request()"""
|
||||
from flask import Flask
|
||||
from filter_insert import main
|
||||
|
||||
# Create a test app context
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context("/ai/admin/users", method="GET"):
|
||||
mock_filter_request.return_value = ({"data": "test"}, 200, {})
|
||||
result = main()
|
||||
|
||||
mock_filter_request.assert_called_once()
|
||||
assert result == ({"data": "test"}, 200, {})
|
||||
|
||||
@patch("filter_insert.make_insert_request")
|
||||
def test_main_post_method(self, mock_insert_request):
|
||||
"""Test main() with POST method calls make_insert_request()"""
|
||||
from flask import Flask
|
||||
from filter_insert import main
|
||||
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context("/ai/admin/users", method="POST", json={"name": "Test", "email": "test@example.com"}):
|
||||
mock_insert_request.return_value = ({"id": "123"}, 201, {})
|
||||
result = main()
|
||||
|
||||
mock_insert_request.assert_called_once()
|
||||
assert result == ({"id": "123"}, 201, {})
|
||||
|
||||
def test_main_method_not_allowed(self):
|
||||
"""Test main() with unsupported HTTP method returns 405"""
|
||||
from flask import Flask
|
||||
from filter_insert import main, CORS_HEADERS
|
||||
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context("/ai/admin/users", method="PUT"):
|
||||
result = main()
|
||||
|
||||
expected = ({"error": "Method not allow"}, 405, CORS_HEADERS)
|
||||
assert result == expected
|
||||
|
||||
def test_main_exception_handling(self):
|
||||
"""Test main() catches exceptions and returns 500"""
|
||||
from flask import Flask
|
||||
from filter_insert import main
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
with patch("filter_insert.make_filter_request") as mock_filter:
|
||||
mock_filter.side_effect = Exception("Database connection failed")
|
||||
with app.test_request_context("/ai/admin/users", method="GET"):
|
||||
result = main()
|
||||
|
||||
assert result[1] == 500
|
||||
assert "error" in result[0]
|
||||
|
||||
|
||||
class TestMakeInsertRequest:
|
||||
"""Test cases for make_insert_request() function"""
|
||||
|
||||
@patch("filter_insert.init_db_connection")
|
||||
@patch("filter_insert.request")
|
||||
@patch("filter_insert.db_row_to_dict")
|
||||
def test_make_insert_request_success(self, mock_db_row, mock_request, mock_init_db):
|
||||
"""Test successful user insertion"""
|
||||
from flask import Flask
|
||||
from filter_insert import make_insert_request, CORS_HEADERS
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
mock_request.get_json.return_value = {
|
||||
"name": "John Doe",
|
||||
"email": "john@example.com",
|
||||
"dob": "1990-01-01",
|
||||
"gender": "male"
|
||||
}
|
||||
|
||||
mock_conn = MagicMock()
|
||||
mock_cursor = MagicMock()
|
||||
mock_cursor.fetchone.return_value = ("uuid-123", "John Doe", "1990-01-01", "john@example.com", "male", "2024-01-01", "2024-01-01")
|
||||
mock_cursor.description = [
|
||||
MagicMock(name="id"),
|
||||
MagicMock(name="name"),
|
||||
MagicMock(name="dob"),
|
||||
MagicMock(name="email"),
|
||||
MagicMock(name="gender"),
|
||||
MagicMock(name="created"),
|
||||
MagicMock(name="modified"),
|
||||
]
|
||||
mock_conn.__enter__ = MagicMock(return_value=mock_conn)
|
||||
mock_conn.__exit__ = MagicMock(return_value=False)
|
||||
mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
|
||||
mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
|
||||
mock_init_db.return_value = mock_conn
|
||||
mock_db_row.return_value = {"id": "uuid-123", "name": "John Doe"}
|
||||
|
||||
with app.test_request_context("/ai/admin/users", method="POST", json=mock_request.get_json.return_value):
|
||||
result = make_insert_request()
|
||||
|
||||
assert result[1] == 201
|
||||
assert result[2] == CORS_HEADERS
|
||||
|
||||
@patch("filter_insert.request")
|
||||
def test_make_insert_request_validation_error(self, mock_request):
|
||||
"""Test make_insert_request() with invalid data returns 400"""
|
||||
from flask import Flask
|
||||
from filter_insert import make_insert_request, CORS_HEADERS
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# Invalid email format
|
||||
mock_request.get_json.return_value = {
|
||||
"name": "John Doe",
|
||||
"email": "invalid-email"
|
||||
}
|
||||
|
||||
with app.test_request_context("/ai/admin/users", method="POST", json=mock_request.get_json.return_value):
|
||||
result = make_insert_request()
|
||||
|
||||
assert result[1] == 400
|
||||
assert result[0].json["errorCode"] == "VALIDATION_ERROR"
|
||||
assert result[2] == CORS_HEADERS
|
||||
|
||||
@patch("filter_insert.init_db_connection")
|
||||
@patch("filter_insert.request")
|
||||
def test_make_insert_request_duplicate_error(self, mock_request, mock_init_db):
|
||||
"""Test make_insert_request() with duplicate email returns 409"""
|
||||
from flask import Flask
|
||||
from filter_insert import make_insert_request, CORS_HEADERS
|
||||
from psycopg2 import IntegrityError
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
mock_request.get_json.return_value = {
|
||||
"name": "John Doe",
|
||||
"email": "john@example.com"
|
||||
}
|
||||
|
||||
mock_conn = MagicMock()
|
||||
mock_cursor = MagicMock()
|
||||
mock_conn.__enter__ = MagicMock(return_value=mock_conn)
|
||||
mock_conn.__exit__ = MagicMock(return_value=False)
|
||||
mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
|
||||
mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
|
||||
mock_init_db.return_value = mock_conn
|
||||
|
||||
# Simulate IntegrityError
|
||||
mock_cursor.execute.side_effect = IntegrityError("duplicate key value violates unique constraint")
|
||||
|
||||
with app.test_request_context("/ai/admin/users", method="POST", json=mock_request.get_json.return_value):
|
||||
result = make_insert_request()
|
||||
|
||||
assert result[1] == 409
|
||||
assert result[0].json["errorCode"] == "DUPLICATE_TAG"
|
||||
assert result[2] == CORS_HEADERS
|
||||
|
||||
|
||||
class TestMakeFilterRequest:
|
||||
"""Test cases for make_filter_request() function"""
|
||||
|
||||
@patch("filter_insert.init_db_connection")
|
||||
@patch("filter_insert.request")
|
||||
@patch("filter_insert.UserPage")
|
||||
@patch("filter_insert.db_rows_to_array")
|
||||
def test_make_filter_request_success(self, mock_db_rows, mock_page_class, mock_request, mock_init_db):
|
||||
"""Test successful filter request"""
|
||||
from flask import Flask
|
||||
from filter_insert import make_filter_request
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
mock_paging = MagicMock()
|
||||
mock_paging.size = 8
|
||||
mock_paging.page = 0
|
||||
mock_paging.filter = MagicMock()
|
||||
mock_paging.filter.ids = None
|
||||
mock_paging.filter.keyword = None
|
||||
mock_paging.filter.name = None
|
||||
mock_paging.filter.email = None
|
||||
mock_paging.filter.created_from = None
|
||||
mock_paging.filter.created_to = None
|
||||
mock_paging.filter.modified_from = None
|
||||
mock_paging.filter.modified_to = None
|
||||
mock_paging.filter.dob_from = None
|
||||
mock_paging.filter.dob_to = None
|
||||
mock_paging.sortby = None
|
||||
mock_paging.asc = None
|
||||
mock_page_class.from_request_queries.return_value = mock_paging
|
||||
|
||||
mock_request.args.get.return_value = None
|
||||
|
||||
mock_conn = MagicMock()
|
||||
mock_cursor = MagicMock()
|
||||
mock_cursor.fetchall.return_value = []
|
||||
mock_conn.cursor.return_value = mock_cursor
|
||||
mock_init_db.return_value = mock_conn
|
||||
|
||||
mock_db_rows.return_value = []
|
||||
|
||||
with app.test_request_context("/ai/admin/users?page=0&size=8"):
|
||||
result = make_filter_request()
|
||||
|
||||
assert result[1] == 200
|
||||
mock_db_rows.assert_called_once()
|
||||
|
||||
|
||||
class TestUserFilter:
|
||||
"""Test cases for UserFilter.from_request_queries()"""
|
||||
|
||||
@patch("filter_insert.request")
|
||||
def test_user_filter_from_queries(self, mock_request):
|
||||
"""Test UserFilter parses query parameters correctly"""
|
||||
from filter_insert import UserFilter
|
||||
|
||||
mock_request.args.get.side_effect = lambda key, default=None: {
|
||||
"filter[ids]": None,
|
||||
"filter[keyword]": "test",
|
||||
"filter[name]": "John",
|
||||
"filter[email]": "john@example.com",
|
||||
"filter[gender]": "male",
|
||||
"filter[created_from]": "2024-01-01",
|
||||
"filter[created_to]": "2024-12-31",
|
||||
"filter[modified_from]": None,
|
||||
"filter[modified_to]": None,
|
||||
"filter[dob_from]": None,
|
||||
"filter[dob_to]": None,
|
||||
}.get(key, default)
|
||||
|
||||
mock_request.args.getlist.return_value = []
|
||||
|
||||
result = UserFilter.from_request_queries()
|
||||
|
||||
assert result.keyword == "test"
|
||||
assert result.name == "John"
|
||||
assert result.email == "john@example.com"
|
||||
assert result.gender == "male"
|
||||
assert result.created_from == "2024-01-01"
|
||||
assert result.created_to == "2024-12-31"
|
||||
|
||||
|
||||
class TestPage:
|
||||
"""Test cases for Page.from_request_queries()"""
|
||||
|
||||
@patch("filter_insert.request")
|
||||
def test_page_default_values(self, mock_request):
|
||||
"""Test Page uses default values when params not provided"""
|
||||
from filter_insert import Page
|
||||
|
||||
mock_request.args.get.side_effect = lambda key, default=None, type=None: {
|
||||
"page": 0,
|
||||
"size": 8,
|
||||
"asc": "false",
|
||||
}.get(key, default)
|
||||
|
||||
result = Page.from_request_queries()
|
||||
|
||||
assert result.page == 0
|
||||
assert result.size == 8
|
||||
assert result.asc is False
|
||||
|
||||
|
||||
class TestUserPage:
|
||||
"""Test cases for UserPage.from_request_queries()"""
|
||||
|
||||
@patch("filter_insert.request")
|
||||
def test_user_page_with_sortby(self, mock_request):
|
||||
"""Test UserPage parses sortby parameter correctly"""
|
||||
from filter_insert import UserPage, UserSortField
|
||||
|
||||
# Simulate request.args.get behavior
|
||||
def mock_get(key, default=None, type=None):
|
||||
values = {
|
||||
"page": 0,
|
||||
"size": 8,
|
||||
"asc": "true",
|
||||
"sortby": "created",
|
||||
}
|
||||
return values.get(key, default)
|
||||
|
||||
mock_request.args.get = mock_get
|
||||
mock_request.args.getlist.return_value = []
|
||||
|
||||
result = UserPage.from_request_queries()
|
||||
|
||||
assert result.sortby == UserSortField.CREATED
|
||||
assert result.asc is True
|
||||
|
||||
@patch("filter_insert.request")
|
||||
def test_user_page_invalid_sortby(self, mock_request):
|
||||
"""Test UserPage handles invalid sortby gracefully"""
|
||||
from filter_insert import UserPage
|
||||
|
||||
def mock_get(key, default=None, type=None):
|
||||
values = {
|
||||
"page": 0,
|
||||
"size": 8,
|
||||
"asc": "false",
|
||||
"sortby": "invalid_field",
|
||||
}
|
||||
return values.get(key, default)
|
||||
|
||||
mock_request.args.get = mock_get
|
||||
mock_request.args.getlist.return_value = []
|
||||
|
||||
result = UserPage.from_request_queries()
|
||||
|
||||
assert result.sortby is None
|
||||
|
||||
|
||||
class TestUserSortField:
|
||||
"""Test cases for UserSortField enum"""
|
||||
|
||||
def test_user_sort_field_values(self):
|
||||
"""Test UserSortField has correct values"""
|
||||
from filter_insert import UserSortField
|
||||
|
||||
assert UserSortField.CREATED.value == "created"
|
||||
assert UserSortField.MODIFIED.value == "modified"
|
||||
|
||||
|
||||
class TestHelpers:
|
||||
"""Test cases for helper functions"""
|
||||
|
||||
def test_str_to_bool_true(self):
|
||||
"""Test str_to_bool with true values"""
|
||||
from helpers import str_to_bool
|
||||
|
||||
assert str_to_bool("true") is True
|
||||
assert str_to_bool("True") is True
|
||||
assert str_to_bool("TRUE") is True
|
||||
|
||||
def test_str_to_bool_false(self):
|
||||
"""Test str_to_bool with false values"""
|
||||
from helpers import str_to_bool
|
||||
|
||||
assert str_to_bool("false") is False
|
||||
assert str_to_bool("False") is False
|
||||
assert str_to_bool("FALSE") is False
|
||||
|
||||
def test_str_to_bool_none(self):
|
||||
"""Test str_to_bool with invalid values returns None"""
|
||||
from helpers import str_to_bool
|
||||
|
||||
assert str_to_bool("invalid") is None
|
||||
assert str_to_bool("") is None
|
||||
assert str_to_bool(None) is None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Reference in New Issue
Block a user