@malamute/ai-rules 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -0
- package/bin/cli.js +5 -0
- package/configs/_shared/.claude/commands/fix-issue.md +38 -0
- package/configs/_shared/.claude/commands/generate-tests.md +49 -0
- package/configs/_shared/.claude/commands/review-pr.md +77 -0
- package/configs/_shared/.claude/rules/accessibility.md +270 -0
- package/configs/_shared/.claude/rules/performance.md +226 -0
- package/configs/_shared/.claude/rules/security.md +188 -0
- package/configs/_shared/.claude/skills/debug/SKILL.md +118 -0
- package/configs/_shared/.claude/skills/learning/SKILL.md +224 -0
- package/configs/_shared/.claude/skills/review/SKILL.md +86 -0
- package/configs/_shared/.claude/skills/spec/SKILL.md +112 -0
- package/configs/_shared/CLAUDE.md +174 -0
- package/configs/angular/.claude/rules/components.md +257 -0
- package/configs/angular/.claude/rules/state.md +250 -0
- package/configs/angular/.claude/rules/testing.md +422 -0
- package/configs/angular/.claude/settings.json +31 -0
- package/configs/angular/CLAUDE.md +251 -0
- package/configs/dotnet/.claude/rules/api.md +370 -0
- package/configs/dotnet/.claude/rules/architecture.md +199 -0
- package/configs/dotnet/.claude/rules/database/efcore.md +408 -0
- package/configs/dotnet/.claude/rules/testing.md +389 -0
- package/configs/dotnet/.claude/settings.json +9 -0
- package/configs/dotnet/CLAUDE.md +319 -0
- package/configs/nestjs/.claude/rules/auth.md +321 -0
- package/configs/nestjs/.claude/rules/database/prisma.md +305 -0
- package/configs/nestjs/.claude/rules/database/typeorm.md +379 -0
- package/configs/nestjs/.claude/rules/modules.md +215 -0
- package/configs/nestjs/.claude/rules/testing.md +315 -0
- package/configs/nestjs/.claude/rules/validation.md +279 -0
- package/configs/nestjs/.claude/settings.json +15 -0
- package/configs/nestjs/CLAUDE.md +263 -0
- package/configs/nextjs/.claude/rules/components.md +211 -0
- package/configs/nextjs/.claude/rules/state/redux-toolkit.md +429 -0
- package/configs/nextjs/.claude/rules/state/zustand.md +299 -0
- package/configs/nextjs/.claude/rules/testing.md +315 -0
- package/configs/nextjs/.claude/settings.json +29 -0
- package/configs/nextjs/CLAUDE.md +376 -0
- package/configs/python/.claude/rules/database/sqlalchemy.md +355 -0
- package/configs/python/.claude/rules/fastapi.md +272 -0
- package/configs/python/.claude/rules/flask.md +332 -0
- package/configs/python/.claude/rules/testing.md +374 -0
- package/configs/python/.claude/settings.json +18 -0
- package/configs/python/CLAUDE.md +273 -0
- package/package.json +41 -0
- package/src/install.js +315 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/*.py"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Flask Rules
|
|
7
|
+
|
|
8
|
+
## Application Factory
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from flask import Flask
|
|
12
|
+
from flask_sqlalchemy import SQLAlchemy
|
|
13
|
+
from flask_migrate import Migrate
|
|
14
|
+
|
|
15
|
+
db = SQLAlchemy()
|
|
16
|
+
migrate = Migrate()
|
|
17
|
+
|
|
18
|
+
def create_app(config_name: str = "development") -> Flask:
|
|
19
|
+
app = Flask(__name__)
|
|
20
|
+
app.config.from_object(config[config_name])
|
|
21
|
+
|
|
22
|
+
# Initialize extensions
|
|
23
|
+
db.init_app(app)
|
|
24
|
+
migrate.init_app(app, db)
|
|
25
|
+
|
|
26
|
+
# Register blueprints
|
|
27
|
+
from app.users import bp as users_bp
|
|
28
|
+
from app.auth import bp as auth_bp
|
|
29
|
+
|
|
30
|
+
app.register_blueprint(users_bp, url_prefix="/api/v1/users")
|
|
31
|
+
app.register_blueprint(auth_bp, url_prefix="/api/v1/auth")
|
|
32
|
+
|
|
33
|
+
# Register error handlers
|
|
34
|
+
register_error_handlers(app)
|
|
35
|
+
|
|
36
|
+
return app
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Blueprints
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from flask import Blueprint, request, jsonify
|
|
43
|
+
from http import HTTPStatus
|
|
44
|
+
|
|
45
|
+
bp = Blueprint("users", __name__)
|
|
46
|
+
|
|
47
|
+
@bp.get("/")
|
|
48
|
+
def list_users():
|
|
49
|
+
"""List all users with pagination."""
|
|
50
|
+
page = request.args.get("page", 1, type=int)
|
|
51
|
+
per_page = request.args.get("per_page", 20, type=int)
|
|
52
|
+
|
|
53
|
+
pagination = User.query.paginate(page=page, per_page=per_page)
|
|
54
|
+
|
|
55
|
+
return jsonify({
|
|
56
|
+
"items": [user.to_dict() for user in pagination.items],
|
|
57
|
+
"total": pagination.total,
|
|
58
|
+
"page": pagination.page,
|
|
59
|
+
"pages": pagination.pages,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@bp.get("/<int:user_id>")
|
|
64
|
+
def get_user(user_id: int):
|
|
65
|
+
"""Get a user by ID."""
|
|
66
|
+
user = db.get_or_404(User, user_id)
|
|
67
|
+
return jsonify(user.to_dict())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@bp.post("/")
|
|
71
|
+
def create_user():
|
|
72
|
+
"""Create a new user."""
|
|
73
|
+
data = request.get_json()
|
|
74
|
+
|
|
75
|
+
# Validation
|
|
76
|
+
errors = validate_user_data(data)
|
|
77
|
+
if errors:
|
|
78
|
+
return jsonify({"errors": errors}), HTTPStatus.BAD_REQUEST
|
|
79
|
+
|
|
80
|
+
user = User(
|
|
81
|
+
email=data["email"],
|
|
82
|
+
name=data["name"],
|
|
83
|
+
)
|
|
84
|
+
user.set_password(data["password"])
|
|
85
|
+
|
|
86
|
+
db.session.add(user)
|
|
87
|
+
db.session.commit()
|
|
88
|
+
|
|
89
|
+
return jsonify(user.to_dict()), HTTPStatus.CREATED
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@bp.put("/<int:user_id>")
|
|
93
|
+
def update_user(user_id: int):
|
|
94
|
+
"""Update a user."""
|
|
95
|
+
user = db.get_or_404(User, user_id)
|
|
96
|
+
data = request.get_json()
|
|
97
|
+
|
|
98
|
+
if "email" in data:
|
|
99
|
+
user.email = data["email"]
|
|
100
|
+
if "name" in data:
|
|
101
|
+
user.name = data["name"]
|
|
102
|
+
|
|
103
|
+
db.session.commit()
|
|
104
|
+
return jsonify(user.to_dict())
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@bp.delete("/<int:user_id>")
|
|
108
|
+
def delete_user(user_id: int):
|
|
109
|
+
"""Delete a user."""
|
|
110
|
+
user = db.get_or_404(User, user_id)
|
|
111
|
+
db.session.delete(user)
|
|
112
|
+
db.session.commit()
|
|
113
|
+
return "", HTTPStatus.NO_CONTENT
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Models with Flask-SQLAlchemy
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from datetime import datetime
|
|
120
|
+
from werkzeug.security import generate_password_hash, check_password_hash
|
|
121
|
+
from app import db
|
|
122
|
+
|
|
123
|
+
class TimestampMixin:
|
|
124
|
+
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
|
125
|
+
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
126
|
+
|
|
127
|
+
class User(TimestampMixin, db.Model):
|
|
128
|
+
__tablename__ = "users"
|
|
129
|
+
|
|
130
|
+
id = db.Column(db.Integer, primary_key=True)
|
|
131
|
+
email = db.Column(db.String(256), unique=True, nullable=False, index=True)
|
|
132
|
+
password_hash = db.Column(db.String(256), nullable=False)
|
|
133
|
+
name = db.Column(db.String(100))
|
|
134
|
+
role = db.Column(db.String(50), default="user")
|
|
135
|
+
|
|
136
|
+
# Relationships
|
|
137
|
+
posts = db.relationship("Post", back_populates="author", lazy="dynamic")
|
|
138
|
+
|
|
139
|
+
def set_password(self, password: str) -> None:
|
|
140
|
+
self.password_hash = generate_password_hash(password)
|
|
141
|
+
|
|
142
|
+
def check_password(self, password: str) -> bool:
|
|
143
|
+
return check_password_hash(self.password_hash, password)
|
|
144
|
+
|
|
145
|
+
def to_dict(self) -> dict:
|
|
146
|
+
return {
|
|
147
|
+
"id": self.id,
|
|
148
|
+
"email": self.email,
|
|
149
|
+
"name": self.name,
|
|
150
|
+
"role": self.role,
|
|
151
|
+
"created_at": self.created_at.isoformat(),
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Error Handling
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
from flask import jsonify
|
|
159
|
+
from werkzeug.exceptions import HTTPException
|
|
160
|
+
|
|
161
|
+
def register_error_handlers(app: Flask) -> None:
|
|
162
|
+
|
|
163
|
+
@app.errorhandler(HTTPException)
|
|
164
|
+
def handle_http_exception(error: HTTPException):
|
|
165
|
+
return jsonify({
|
|
166
|
+
"error": error.name,
|
|
167
|
+
"message": error.description,
|
|
168
|
+
}), error.code
|
|
169
|
+
|
|
170
|
+
@app.errorhandler(ValidationError)
|
|
171
|
+
def handle_validation_error(error: ValidationError):
|
|
172
|
+
return jsonify({
|
|
173
|
+
"error": "Validation Error",
|
|
174
|
+
"message": str(error),
|
|
175
|
+
"details": error.errors,
|
|
176
|
+
}), HTTPStatus.BAD_REQUEST
|
|
177
|
+
|
|
178
|
+
@app.errorhandler(Exception)
|
|
179
|
+
def handle_generic_exception(error: Exception):
|
|
180
|
+
app.logger.exception("Unhandled exception")
|
|
181
|
+
return jsonify({
|
|
182
|
+
"error": "Internal Server Error",
|
|
183
|
+
"message": "An unexpected error occurred",
|
|
184
|
+
}), HTTPStatus.INTERNAL_SERVER_ERROR
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Authentication with Flask-JWT-Extended
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
from flask_jwt_extended import (
|
|
191
|
+
JWTManager,
|
|
192
|
+
create_access_token,
|
|
193
|
+
create_refresh_token,
|
|
194
|
+
jwt_required,
|
|
195
|
+
get_jwt_identity,
|
|
196
|
+
current_user,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
jwt = JWTManager()
|
|
200
|
+
|
|
201
|
+
@jwt.user_identity_loader
|
|
202
|
+
def user_identity_lookup(user: User) -> int:
|
|
203
|
+
return user.id
|
|
204
|
+
|
|
205
|
+
@jwt.user_lookup_loader
|
|
206
|
+
def user_lookup_callback(_jwt_header, jwt_data) -> User | None:
|
|
207
|
+
identity = jwt_data["sub"]
|
|
208
|
+
return User.query.get(identity)
|
|
209
|
+
|
|
210
|
+
# Auth blueprint
|
|
211
|
+
auth_bp = Blueprint("auth", __name__)
|
|
212
|
+
|
|
213
|
+
@auth_bp.post("/login")
|
|
214
|
+
def login():
|
|
215
|
+
data = request.get_json()
|
|
216
|
+
user = User.query.filter_by(email=data["email"]).first()
|
|
217
|
+
|
|
218
|
+
if not user or not user.check_password(data["password"]):
|
|
219
|
+
return jsonify({"error": "Invalid credentials"}), HTTPStatus.UNAUTHORIZED
|
|
220
|
+
|
|
221
|
+
return jsonify({
|
|
222
|
+
"access_token": create_access_token(identity=user),
|
|
223
|
+
"refresh_token": create_refresh_token(identity=user),
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
@auth_bp.post("/refresh")
|
|
227
|
+
@jwt_required(refresh=True)
|
|
228
|
+
def refresh():
|
|
229
|
+
user = current_user
|
|
230
|
+
return jsonify({
|
|
231
|
+
"access_token": create_access_token(identity=user),
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
# Protected route
|
|
235
|
+
@bp.get("/me")
|
|
236
|
+
@jwt_required()
|
|
237
|
+
def get_current_user():
|
|
238
|
+
return jsonify(current_user.to_dict())
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Request Validation with Marshmallow
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
from marshmallow import Schema, fields, validate, ValidationError, post_load
|
|
245
|
+
|
|
246
|
+
class UserCreateSchema(Schema):
|
|
247
|
+
email = fields.Email(required=True)
|
|
248
|
+
password = fields.Str(required=True, validate=validate.Length(min=8))
|
|
249
|
+
name = fields.Str(required=True, validate=validate.Length(min=1, max=100))
|
|
250
|
+
|
|
251
|
+
class UserUpdateSchema(Schema):
|
|
252
|
+
email = fields.Email()
|
|
253
|
+
name = fields.Str(validate=validate.Length(max=100))
|
|
254
|
+
|
|
255
|
+
class UserResponseSchema(Schema):
|
|
256
|
+
id = fields.Int(dump_only=True)
|
|
257
|
+
email = fields.Email()
|
|
258
|
+
name = fields.Str()
|
|
259
|
+
created_at = fields.DateTime(dump_only=True)
|
|
260
|
+
|
|
261
|
+
# Usage in route
|
|
262
|
+
@bp.post("/")
|
|
263
|
+
def create_user():
|
|
264
|
+
schema = UserCreateSchema()
|
|
265
|
+
try:
|
|
266
|
+
data = schema.load(request.get_json())
|
|
267
|
+
except ValidationError as err:
|
|
268
|
+
return jsonify({"errors": err.messages}), HTTPStatus.BAD_REQUEST
|
|
269
|
+
|
|
270
|
+
# Create user...
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## Configuration
|
|
274
|
+
|
|
275
|
+
```python
|
|
276
|
+
import os
|
|
277
|
+
|
|
278
|
+
class Config:
|
|
279
|
+
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-key")
|
|
280
|
+
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
|
281
|
+
JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "jwt-secret")
|
|
282
|
+
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)
|
|
283
|
+
|
|
284
|
+
class DevelopmentConfig(Config):
|
|
285
|
+
DEBUG = True
|
|
286
|
+
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
|
287
|
+
"DATABASE_URL", "sqlite:///dev.db"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
class ProductionConfig(Config):
|
|
291
|
+
DEBUG = False
|
|
292
|
+
SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"]
|
|
293
|
+
|
|
294
|
+
class TestingConfig(Config):
|
|
295
|
+
TESTING = True
|
|
296
|
+
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
|
|
297
|
+
|
|
298
|
+
config = {
|
|
299
|
+
"development": DevelopmentConfig,
|
|
300
|
+
"production": ProductionConfig,
|
|
301
|
+
"testing": TestingConfig,
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
## CLI Commands
|
|
306
|
+
|
|
307
|
+
```python
|
|
308
|
+
import click
|
|
309
|
+
from flask.cli import with_appcontext
|
|
310
|
+
|
|
311
|
+
@app.cli.command("seed-db")
|
|
312
|
+
@with_appcontext
|
|
313
|
+
def seed_db():
|
|
314
|
+
"""Seed the database with initial data."""
|
|
315
|
+
admin = User(email="admin@example.com", name="Admin", role="admin")
|
|
316
|
+
admin.set_password("admin123")
|
|
317
|
+
db.session.add(admin)
|
|
318
|
+
db.session.commit()
|
|
319
|
+
click.echo("Database seeded!")
|
|
320
|
+
|
|
321
|
+
@app.cli.command("create-admin")
|
|
322
|
+
@click.argument("email")
|
|
323
|
+
@click.password_option()
|
|
324
|
+
@with_appcontext
|
|
325
|
+
def create_admin(email: str, password: str):
|
|
326
|
+
"""Create an admin user."""
|
|
327
|
+
user = User(email=email, role="admin")
|
|
328
|
+
user.set_password(password)
|
|
329
|
+
db.session.add(user)
|
|
330
|
+
db.session.commit()
|
|
331
|
+
click.echo(f"Admin {email} created!")
|
|
332
|
+
```
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "tests/**/*.py"
|
|
4
|
+
- "**/test_*.py"
|
|
5
|
+
- "**/*_test.py"
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Python Testing Rules
|
|
9
|
+
|
|
10
|
+
## pytest Structure
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
tests/
|
|
14
|
+
├── conftest.py # Shared fixtures
|
|
15
|
+
├── unit/
|
|
16
|
+
│ ├── test_services.py
|
|
17
|
+
│ └── test_utils.py
|
|
18
|
+
├── integration/
|
|
19
|
+
│ ├── test_repositories.py
|
|
20
|
+
│ └── test_database.py
|
|
21
|
+
└── e2e/
|
|
22
|
+
├── test_users_api.py
|
|
23
|
+
└── test_auth_api.py
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Fixtures (conftest.py)
|
|
27
|
+
|
|
28
|
+
### FastAPI Fixtures
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import pytest
|
|
32
|
+
from httpx import AsyncClient, ASGITransport
|
|
33
|
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
|
34
|
+
from sqlalchemy.orm import sessionmaker
|
|
35
|
+
|
|
36
|
+
from app.main import app
|
|
37
|
+
from app.database import Base, get_db
|
|
38
|
+
from app.config import settings
|
|
39
|
+
|
|
40
|
+
# Test database
|
|
41
|
+
TEST_DATABASE_URL = "sqlite+aiosqlite:///./test.db"
|
|
42
|
+
|
|
43
|
+
@pytest.fixture(scope="session")
|
|
44
|
+
def event_loop():
|
|
45
|
+
"""Create event loop for async tests."""
|
|
46
|
+
import asyncio
|
|
47
|
+
loop = asyncio.new_event_loop()
|
|
48
|
+
yield loop
|
|
49
|
+
loop.close()
|
|
50
|
+
|
|
51
|
+
@pytest.fixture(scope="session")
|
|
52
|
+
async def engine():
|
|
53
|
+
"""Create test database engine."""
|
|
54
|
+
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
|
55
|
+
async with engine.begin() as conn:
|
|
56
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
57
|
+
yield engine
|
|
58
|
+
async with engine.begin() as conn:
|
|
59
|
+
await conn.run_sync(Base.metadata.drop_all)
|
|
60
|
+
await engine.dispose()
|
|
61
|
+
|
|
62
|
+
@pytest.fixture
|
|
63
|
+
async def db_session(engine):
|
|
64
|
+
"""Create a new database session for each test."""
|
|
65
|
+
async_session = sessionmaker(
|
|
66
|
+
engine, class_=AsyncSession, expire_on_commit=False
|
|
67
|
+
)
|
|
68
|
+
async with async_session() as session:
|
|
69
|
+
yield session
|
|
70
|
+
await session.rollback()
|
|
71
|
+
|
|
72
|
+
@pytest.fixture
|
|
73
|
+
async def client(db_session):
|
|
74
|
+
"""Create test client with overridden dependencies."""
|
|
75
|
+
async def override_get_db():
|
|
76
|
+
yield db_session
|
|
77
|
+
|
|
78
|
+
app.dependency_overrides[get_db] = override_get_db
|
|
79
|
+
|
|
80
|
+
async with AsyncClient(
|
|
81
|
+
transport=ASGITransport(app=app),
|
|
82
|
+
base_url="http://test",
|
|
83
|
+
) as client:
|
|
84
|
+
yield client
|
|
85
|
+
|
|
86
|
+
app.dependency_overrides.clear()
|
|
87
|
+
|
|
88
|
+
@pytest.fixture
|
|
89
|
+
async def authenticated_client(client, db_session):
|
|
90
|
+
"""Client with authentication token."""
|
|
91
|
+
# Create test user
|
|
92
|
+
user = User(email="test@example.com", name="Test")
|
|
93
|
+
user.set_password("password123")
|
|
94
|
+
db_session.add(user)
|
|
95
|
+
await db_session.commit()
|
|
96
|
+
|
|
97
|
+
# Get token
|
|
98
|
+
response = await client.post("/api/v1/auth/login", json={
|
|
99
|
+
"email": "test@example.com",
|
|
100
|
+
"password": "password123",
|
|
101
|
+
})
|
|
102
|
+
token = response.json()["access_token"]
|
|
103
|
+
|
|
104
|
+
client.headers["Authorization"] = f"Bearer {token}"
|
|
105
|
+
return client
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Flask Fixtures
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
import pytest
|
|
112
|
+
from app import create_app, db as _db
|
|
113
|
+
|
|
114
|
+
@pytest.fixture(scope="session")
|
|
115
|
+
def app():
|
|
116
|
+
"""Create application for testing."""
|
|
117
|
+
app = create_app("testing")
|
|
118
|
+
return app
|
|
119
|
+
|
|
120
|
+
@pytest.fixture
|
|
121
|
+
def client(app):
|
|
122
|
+
"""Create test client."""
|
|
123
|
+
return app.test_client()
|
|
124
|
+
|
|
125
|
+
@pytest.fixture
|
|
126
|
+
def db(app):
|
|
127
|
+
"""Create database for testing."""
|
|
128
|
+
with app.app_context():
|
|
129
|
+
_db.create_all()
|
|
130
|
+
yield _db
|
|
131
|
+
_db.drop_all()
|
|
132
|
+
|
|
133
|
+
@pytest.fixture
|
|
134
|
+
def session(db):
|
|
135
|
+
"""Create database session."""
|
|
136
|
+
connection = db.engine.connect()
|
|
137
|
+
transaction = connection.begin()
|
|
138
|
+
|
|
139
|
+
session = db.session
|
|
140
|
+
yield session
|
|
141
|
+
|
|
142
|
+
session.close()
|
|
143
|
+
transaction.rollback()
|
|
144
|
+
connection.close()
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Unit Tests
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
import pytest
|
|
151
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
152
|
+
|
|
153
|
+
from app.users.service import UserService
|
|
154
|
+
from app.users.schemas import UserCreate
|
|
155
|
+
|
|
156
|
+
class TestUserService:
|
|
157
|
+
@pytest.fixture
|
|
158
|
+
def mock_repository(self):
|
|
159
|
+
return AsyncMock()
|
|
160
|
+
|
|
161
|
+
@pytest.fixture
|
|
162
|
+
def service(self, mock_repository):
|
|
163
|
+
return UserService(repository=mock_repository)
|
|
164
|
+
|
|
165
|
+
async def test_create_user_success(self, service, mock_repository):
|
|
166
|
+
# Arrange
|
|
167
|
+
user_data = UserCreate(
|
|
168
|
+
email="test@example.com",
|
|
169
|
+
password="password123",
|
|
170
|
+
name="Test User",
|
|
171
|
+
)
|
|
172
|
+
mock_repository.get_by_email.return_value = None
|
|
173
|
+
mock_repository.create.return_value = User(
|
|
174
|
+
id=1,
|
|
175
|
+
email=user_data.email,
|
|
176
|
+
name=user_data.name,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Act
|
|
180
|
+
result = await service.create(user_data)
|
|
181
|
+
|
|
182
|
+
# Assert
|
|
183
|
+
assert result.email == user_data.email
|
|
184
|
+
mock_repository.create.assert_called_once()
|
|
185
|
+
|
|
186
|
+
async def test_create_user_email_exists_raises(self, service, mock_repository):
|
|
187
|
+
# Arrange
|
|
188
|
+
user_data = UserCreate(
|
|
189
|
+
email="existing@example.com",
|
|
190
|
+
password="password123",
|
|
191
|
+
name="Test",
|
|
192
|
+
)
|
|
193
|
+
mock_repository.get_by_email.return_value = User(id=1, email=user_data.email)
|
|
194
|
+
|
|
195
|
+
# Act & Assert
|
|
196
|
+
with pytest.raises(EmailAlreadyExistsError):
|
|
197
|
+
await service.create(user_data)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Integration Tests (API)
|
|
201
|
+
|
|
202
|
+
### FastAPI Tests
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
import pytest
|
|
206
|
+
from httpx import AsyncClient
|
|
207
|
+
|
|
208
|
+
class TestUsersAPI:
|
|
209
|
+
async def test_create_user(self, client: AsyncClient):
|
|
210
|
+
# Arrange
|
|
211
|
+
user_data = {
|
|
212
|
+
"email": "new@example.com",
|
|
213
|
+
"password": "password123",
|
|
214
|
+
"name": "New User",
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
# Act
|
|
218
|
+
response = await client.post("/api/v1/users", json=user_data)
|
|
219
|
+
|
|
220
|
+
# Assert
|
|
221
|
+
assert response.status_code == 201
|
|
222
|
+
data = response.json()
|
|
223
|
+
assert data["email"] == user_data["email"]
|
|
224
|
+
assert "id" in data
|
|
225
|
+
assert "password" not in data
|
|
226
|
+
|
|
227
|
+
async def test_create_user_invalid_email(self, client: AsyncClient):
|
|
228
|
+
# Arrange
|
|
229
|
+
user_data = {
|
|
230
|
+
"email": "invalid",
|
|
231
|
+
"password": "password123",
|
|
232
|
+
"name": "Test",
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
# Act
|
|
236
|
+
response = await client.post("/api/v1/users", json=user_data)
|
|
237
|
+
|
|
238
|
+
# Assert
|
|
239
|
+
assert response.status_code == 422
|
|
240
|
+
assert "email" in response.json()["detail"][0]["loc"]
|
|
241
|
+
|
|
242
|
+
async def test_get_user_not_found(self, client: AsyncClient):
|
|
243
|
+
response = await client.get("/api/v1/users/99999")
|
|
244
|
+
assert response.status_code == 404
|
|
245
|
+
|
|
246
|
+
async def test_list_users_with_pagination(self, client: AsyncClient, db_session):
|
|
247
|
+
# Arrange - create 25 users
|
|
248
|
+
for i in range(25):
|
|
249
|
+
user = User(email=f"user{i}@example.com", name=f"User {i}")
|
|
250
|
+
db_session.add(user)
|
|
251
|
+
await db_session.commit()
|
|
252
|
+
|
|
253
|
+
# Act
|
|
254
|
+
response = await client.get("/api/v1/users?page=2&size=10")
|
|
255
|
+
|
|
256
|
+
# Assert
|
|
257
|
+
assert response.status_code == 200
|
|
258
|
+
data = response.json()
|
|
259
|
+
assert len(data["items"]) == 10
|
|
260
|
+
assert data["total"] == 25
|
|
261
|
+
assert data["page"] == 2
|
|
262
|
+
|
|
263
|
+
async def test_protected_route_unauthorized(self, client: AsyncClient):
|
|
264
|
+
response = await client.get("/api/v1/users/me")
|
|
265
|
+
assert response.status_code == 401
|
|
266
|
+
|
|
267
|
+
async def test_protected_route_authorized(self, authenticated_client: AsyncClient):
|
|
268
|
+
response = await authenticated_client.get("/api/v1/users/me")
|
|
269
|
+
assert response.status_code == 200
|
|
270
|
+
assert "email" in response.json()
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Flask Tests
|
|
274
|
+
|
|
275
|
+
```python
|
|
276
|
+
import pytest
|
|
277
|
+
|
|
278
|
+
class TestUsersAPI:
|
|
279
|
+
def test_create_user(self, client):
|
|
280
|
+
response = client.post("/api/v1/users", json={
|
|
281
|
+
"email": "new@example.com",
|
|
282
|
+
"password": "password123",
|
|
283
|
+
"name": "New User",
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
assert response.status_code == 201
|
|
287
|
+
assert response.json["email"] == "new@example.com"
|
|
288
|
+
|
|
289
|
+
def test_get_user(self, client, session):
|
|
290
|
+
# Create user
|
|
291
|
+
user = User(email="test@example.com", name="Test")
|
|
292
|
+
session.add(user)
|
|
293
|
+
session.commit()
|
|
294
|
+
|
|
295
|
+
response = client.get(f"/api/v1/users/{user.id}")
|
|
296
|
+
|
|
297
|
+
assert response.status_code == 200
|
|
298
|
+
assert response.json["id"] == user.id
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
## Parametrized Tests
|
|
302
|
+
|
|
303
|
+
```python
|
|
304
|
+
import pytest
|
|
305
|
+
|
|
306
|
+
@pytest.mark.parametrize("email,expected_valid", [
|
|
307
|
+
("valid@example.com", True),
|
|
308
|
+
("also.valid@example.co.uk", True),
|
|
309
|
+
("invalid", False),
|
|
310
|
+
("missing@", False),
|
|
311
|
+
("@nodomain.com", False),
|
|
312
|
+
])
|
|
313
|
+
def test_email_validation(email: str, expected_valid: bool):
|
|
314
|
+
schema = UserCreateSchema()
|
|
315
|
+
if expected_valid:
|
|
316
|
+
result = schema.load({"email": email, "password": "12345678", "name": "Test"})
|
|
317
|
+
assert result["email"] == email
|
|
318
|
+
else:
|
|
319
|
+
with pytest.raises(ValidationError):
|
|
320
|
+
schema.load({"email": email, "password": "12345678", "name": "Test"})
|
|
321
|
+
|
|
322
|
+
@pytest.mark.parametrize("password,should_pass", [
|
|
323
|
+
("short", False),
|
|
324
|
+
("longenough", True),
|
|
325
|
+
("12345678", True),
|
|
326
|
+
])
|
|
327
|
+
def test_password_validation(password: str, should_pass: bool):
|
|
328
|
+
...
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
## Markers
|
|
332
|
+
|
|
333
|
+
```python
|
|
334
|
+
# pytest.ini or pyproject.toml
|
|
335
|
+
[tool.pytest.ini_options]
|
|
336
|
+
markers = [
|
|
337
|
+
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
|
|
338
|
+
"integration: marks tests that require database",
|
|
339
|
+
"e2e: marks end-to-end tests",
|
|
340
|
+
]
|
|
341
|
+
|
|
342
|
+
# Usage
|
|
343
|
+
@pytest.mark.slow
|
|
344
|
+
async def test_heavy_computation():
|
|
345
|
+
...
|
|
346
|
+
|
|
347
|
+
@pytest.mark.integration
|
|
348
|
+
async def test_database_query():
|
|
349
|
+
...
|
|
350
|
+
|
|
351
|
+
# Run specific markers
|
|
352
|
+
# pytest -m "not slow"
|
|
353
|
+
# pytest -m integration
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
## Coverage
|
|
357
|
+
|
|
358
|
+
```bash
|
|
359
|
+
# Run with coverage
|
|
360
|
+
pytest --cov=app --cov-report=html --cov-report=term-missing
|
|
361
|
+
|
|
362
|
+
# Coverage config in pyproject.toml
|
|
363
|
+
[tool.coverage.run]
|
|
364
|
+
source = ["app"]
|
|
365
|
+
omit = ["*/tests/*", "*/__init__.py"]
|
|
366
|
+
|
|
367
|
+
[tool.coverage.report]
|
|
368
|
+
exclude_lines = [
|
|
369
|
+
"pragma: no cover",
|
|
370
|
+
"if TYPE_CHECKING:",
|
|
371
|
+
"raise NotImplementedError",
|
|
372
|
+
]
|
|
373
|
+
fail_under = 80
|
|
374
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(python *)",
|
|
5
|
+
"Bash(uvicorn *)",
|
|
6
|
+
"Bash(fastapi *)",
|
|
7
|
+
"Bash(flask *)",
|
|
8
|
+
"Bash(pytest *)",
|
|
9
|
+
"Bash(ruff *)",
|
|
10
|
+
"Bash(mypy *)",
|
|
11
|
+
"Bash(alembic *)",
|
|
12
|
+
"Bash(uv *)",
|
|
13
|
+
"Bash(poetry *)",
|
|
14
|
+
"Bash(pip *)"
|
|
15
|
+
],
|
|
16
|
+
"deny": []
|
|
17
|
+
}
|
|
18
|
+
}
|