hinbert-fastapi 0.1.0__py3-none-any.whl
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.
- app/__init__.py +8 -0
- app/api/__init__.py +1 -0
- app/api/deps/__init__.py +1 -0
- app/api/deps/auth.py +40 -0
- app/api/deps/pagination.py +8 -0
- app/api/v1/__init__.py +1 -0
- app/api/v1/endpoints/__init__.py +1 -0
- app/api/v1/endpoints/auth.py +255 -0
- app/api/v1/endpoints/dashboard.py +23 -0
- app/api/v1/endpoints/products.py +102 -0
- app/api/v1/endpoints/users.py +89 -0
- app/api/v1/routers/__init__.py +1 -0
- app/api/v1/routers/api_router.py +11 -0
- app/core/__init__.py +1 -0
- app/core/config/__init__.py +1 -0
- app/core/config/database.py +13 -0
- app/core/config/settings.py +83 -0
- app/core/exceptions/__init__.py +1 -0
- app/core/exceptions/base_exception.py +9 -0
- app/core/exceptions/custom_exceptions.py +17 -0
- app/core/middleware/__init__.py +1 -0
- app/core/middleware/cors.py +5 -0
- app/core/middleware/error_handler.py +23 -0
- app/core/middleware/logging.py +29 -0
- app/core/middleware/rate_limit.py +27 -0
- app/core/security/__init__.py +1 -0
- app/core/security/auth.py +8 -0
- app/core/security/jwt.py +33 -0
- app/core/security/oauth.py +8 -0
- app/core/security/password.py +18 -0
- app/core/security/totp.py +13 -0
- app/db/__init__.py +1 -0
- app/db/base.py +7 -0
- app/db/migrations/env.py +39 -0
- app/db/migrations/versions/97ed1bc05f4a_complete_product_and_totp_fields.py +49 -0
- app/db/migrations/versions/f8b632aa11da_initial_migration.py +113 -0
- app/db/session.py +11 -0
- app/main.py +50 -0
- app/models/__init__.py +10 -0
- app/models/domain/__init__.py +1 -0
- app/models/domain/base.py +20 -0
- app/models/domain/email_verification.py +24 -0
- app/models/domain/password_reset.py +24 -0
- app/models/domain/product.py +22 -0
- app/models/domain/refresh_token.py +24 -0
- app/models/domain/totp_secret.py +44 -0
- app/models/domain/user.py +34 -0
- app/models/schemas/__init__.py +1 -0
- app/models/schemas/auth.py +30 -0
- app/models/schemas/password.py +16 -0
- app/models/schemas/product.py +31 -0
- app/models/schemas/response.py +17 -0
- app/models/schemas/token.py +17 -0
- app/models/schemas/totp.py +16 -0
- app/models/schemas/user.py +37 -0
- app/repositories/__init__.py +1 -0
- app/repositories/base/__init__.py +1 -0
- app/repositories/base/base_repository.py +51 -0
- app/repositories/email_verification_repository.py +37 -0
- app/repositories/password_reset_repository.py +33 -0
- app/repositories/product_repository.py +40 -0
- app/repositories/refresh_token_repository.py +40 -0
- app/repositories/totp_secret_repository.py +23 -0
- app/repositories/user_repository.py +44 -0
- app/services/__init__.py +1 -0
- app/services/auth_service.py +32 -0
- app/services/base/__init__.py +1 -0
- app/services/base/base_service.py +21 -0
- app/services/email_service.py +32 -0
- app/services/product_service.py +24 -0
- app/services/social_auth_service.py +47 -0
- app/services/totp_service.py +13 -0
- app/services/user_service.py +27 -0
- app/tests/__init__.py +1 -0
- app/tests/conftest.py +36 -0
- app/tests/integration/test_auth_api.py +115 -0
- app/tests/integration/test_product_api.py +58 -0
- app/tests/integration/test_user_api.py +68 -0
- app/tests/unit/test_auth_service.py +48 -0
- app/tests/unit/test_product_service.py +33 -0
- app/tests/unit/test_user_service.py +32 -0
- app/utils/__init__.py +1 -0
- app/utils/date_utils.py +8 -0
- app/utils/file_utils.py +8 -0
- app/utils/logger.py +11 -0
- app/utils/validators.py +13 -0
- hinbert_fastapi-0.1.0.dist-info/METADATA +0 -0
- hinbert_fastapi-0.1.0.dist-info/RECORD +96 -0
- hinbert_fastapi-0.1.0.dist-info/WHEEL +5 -0
- hinbert_fastapi-0.1.0.dist-info/entry_points.txt +5 -0
- hinbert_fastapi-0.1.0.dist-info/licenses/LICENSE +21 -0
- hinbert_fastapi-0.1.0.dist-info/top_level.txt +2 -0
- scripts/__init__.py +1 -0
- scripts/create_admin.py +41 -0
- scripts/run_migrations.py +13 -0
- scripts/seed_data.py +44 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Social-login provider adapters for Google and Facebook."""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from app.core.config.settings import get_settings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def exchange_provider_code(provider: str, code: str) -> dict[str, str]:
|
|
9
|
+
"""Exchange an authorization code for normalized provider user information."""
|
|
10
|
+
if provider not in {"google", "facebook"}:
|
|
11
|
+
raise ValueError("Unsupported provider")
|
|
12
|
+
settings = get_settings()
|
|
13
|
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
14
|
+
if provider == "google":
|
|
15
|
+
token_response = await client.post(
|
|
16
|
+
"https://oauth2.googleapis.com/token",
|
|
17
|
+
data={
|
|
18
|
+
"code": code,
|
|
19
|
+
"client_id": settings.google_client_id,
|
|
20
|
+
"client_secret": settings.google_client_secret.get_secret_value(),
|
|
21
|
+
"grant_type": "authorization_code",
|
|
22
|
+
},
|
|
23
|
+
)
|
|
24
|
+
token_response.raise_for_status()
|
|
25
|
+
profile = await client.get(
|
|
26
|
+
"https://openidconnect.googleapis.com/v1/userinfo",
|
|
27
|
+
headers={"Authorization": f"Bearer {token_response.json()['access_token']}"},
|
|
28
|
+
)
|
|
29
|
+
else:
|
|
30
|
+
token_response = await client.get(
|
|
31
|
+
"https://graph.facebook.com/v19.0/oauth/access_token",
|
|
32
|
+
params={
|
|
33
|
+
"client_id": settings.facebook_client_id,
|
|
34
|
+
"client_secret": settings.facebook_client_secret.get_secret_value(),
|
|
35
|
+
"code": code,
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
token_response.raise_for_status()
|
|
39
|
+
profile = await client.get(
|
|
40
|
+
"https://graph.facebook.com/me",
|
|
41
|
+
params={"fields": "id,name,email", "access_token": token_response.json()["access_token"]},
|
|
42
|
+
)
|
|
43
|
+
profile.raise_for_status()
|
|
44
|
+
data = profile.json()
|
|
45
|
+
if not data.get("email"):
|
|
46
|
+
raise ValueError("OAuth provider did not return an email address")
|
|
47
|
+
return {"email": data["email"], "full_name": data.get("name") or data.get("given_name") or "OAuth User"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Two-factor orchestration kept separate from HTTP handlers."""
|
|
2
|
+
|
|
3
|
+
from app.core.security.totp import new_secret, verify_code
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def setup_totp() -> str:
|
|
7
|
+
"""Return a new secret to encrypt and associate with a user."""
|
|
8
|
+
return new_secret()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def verify_totp(secret: str, code: str) -> bool:
|
|
12
|
+
"""Verify a user-provided current code."""
|
|
13
|
+
return verify_code(secret, code)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""User business rules and safe profile operations."""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
4
|
+
|
|
5
|
+
from app.core.security.password import hash_password
|
|
6
|
+
from app.models.domain.user import User
|
|
7
|
+
from app.models.schemas.user import UserCreate, UserUpdate
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def create_user(session: AsyncSession, payload: UserCreate) -> User:
|
|
11
|
+
"""Create a user with a bcrypt hash and commit the transaction."""
|
|
12
|
+
user = User(
|
|
13
|
+
email=str(payload.email).lower(), full_name=payload.full_name, password_hash=hash_password(payload.password)
|
|
14
|
+
)
|
|
15
|
+
session.add(user)
|
|
16
|
+
await session.commit()
|
|
17
|
+
await session.refresh(user)
|
|
18
|
+
return user
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def update_user(session: AsyncSession, user: User, payload: UserUpdate) -> User:
|
|
22
|
+
"""Apply allowed profile fields and persist them."""
|
|
23
|
+
if payload.full_name is not None:
|
|
24
|
+
user.full_name = payload.full_name
|
|
25
|
+
await session.commit()
|
|
26
|
+
await session.refresh(user)
|
|
27
|
+
return user
|
app/tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Test package for unit and integration coverage."""
|
app/tests/conftest.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Shared asynchronous database and HTTP fixtures for isolated application tests."""
|
|
2
|
+
|
|
3
|
+
import pytest_asyncio
|
|
4
|
+
from httpx import ASGITransport, AsyncClient
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
6
|
+
|
|
7
|
+
from app.db.base import Base
|
|
8
|
+
from app.db.session import get_db
|
|
9
|
+
from app.main import app
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest_asyncio.fixture
|
|
13
|
+
async def database_session():
|
|
14
|
+
"""Create an in-memory schema and roll back all test data after each test."""
|
|
15
|
+
engine = create_async_engine("sqlite+aiosqlite:///:memory:", pool_pre_ping=True)
|
|
16
|
+
async with engine.begin() as connection:
|
|
17
|
+
await connection.run_sync(Base.metadata.create_all)
|
|
18
|
+
session_factory = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
|
19
|
+
async with session_factory() as session:
|
|
20
|
+
yield session
|
|
21
|
+
await session.rollback()
|
|
22
|
+
await engine.dispose()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest_asyncio.fixture
|
|
26
|
+
async def client(database_session: AsyncSession):
|
|
27
|
+
"""Yield an HTTP client whose database dependency uses the isolated session."""
|
|
28
|
+
|
|
29
|
+
async def override_get_db():
|
|
30
|
+
"""Yield the current test transaction to FastAPI dependencies."""
|
|
31
|
+
yield database_session
|
|
32
|
+
|
|
33
|
+
app.dependency_overrides[get_db] = override_get_db
|
|
34
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as test_client:
|
|
35
|
+
yield test_client
|
|
36
|
+
app.dependency_overrides.clear()
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Integration tests for credential, token, and TOTP authentication flows."""
|
|
2
|
+
|
|
3
|
+
import pyotp
|
|
4
|
+
import pytest
|
|
5
|
+
from httpx import AsyncClient
|
|
6
|
+
|
|
7
|
+
from app.api.v1.endpoints import auth as auth_endpoint
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.mark.asyncio
|
|
11
|
+
async def test_signup_and_login(client: AsyncClient):
|
|
12
|
+
"""Signup returns a safe user and login returns both token types."""
|
|
13
|
+
signup = await client.post(
|
|
14
|
+
"/api/v1/auth/signup",
|
|
15
|
+
json={"email": "user@example.com", "full_name": "Test User", "password": "StrongPassword!123"},
|
|
16
|
+
)
|
|
17
|
+
assert signup.status_code == 201
|
|
18
|
+
assert "password_hash" not in signup.text
|
|
19
|
+
login = await client.post(
|
|
20
|
+
"/api/v1/auth/login", json={"email": "user@example.com", "password": "StrongPassword!123"}
|
|
21
|
+
)
|
|
22
|
+
assert login.status_code == 200
|
|
23
|
+
assert login.json()["data"]["access_token"]
|
|
24
|
+
assert login.json()["data"]["refresh_token"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.mark.asyncio
|
|
28
|
+
async def test_refresh_rotation_and_logout(client: AsyncClient):
|
|
29
|
+
"""A refresh token rotates once and logout revokes its replacement."""
|
|
30
|
+
await client.post(
|
|
31
|
+
"/api/v1/auth/signup",
|
|
32
|
+
json={"email": "rotate@example.com", "full_name": "Rotate User", "password": "StrongPassword!123"},
|
|
33
|
+
)
|
|
34
|
+
login = await client.post(
|
|
35
|
+
"/api/v1/auth/login", json={"email": "rotate@example.com", "password": "StrongPassword!123"}
|
|
36
|
+
)
|
|
37
|
+
first_refresh = login.json()["data"]["refresh_token"]
|
|
38
|
+
refreshed = await client.post("/api/v1/auth/refresh", json={"refresh_token": first_refresh})
|
|
39
|
+
assert refreshed.status_code == 200
|
|
40
|
+
second_refresh = refreshed.json()["data"]["refresh_token"]
|
|
41
|
+
assert second_refresh != first_refresh
|
|
42
|
+
assert (await client.post("/api/v1/auth/refresh", json={"refresh_token": first_refresh})).status_code == 401
|
|
43
|
+
assert (await client.post("/api/v1/auth/logout", json={"refresh_token": second_refresh})).status_code == 200
|
|
44
|
+
assert (await client.post("/api/v1/auth/refresh", json={"refresh_token": second_refresh})).status_code == 401
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.mark.asyncio
|
|
48
|
+
async def test_invalid_login_returns_401(client: AsyncClient):
|
|
49
|
+
"""Invalid credentials are rejected without account detail leakage."""
|
|
50
|
+
response = await client.post(
|
|
51
|
+
"/api/v1/auth/login", json={"email": "missing@example.com", "password": "WrongPassword!123"}
|
|
52
|
+
)
|
|
53
|
+
assert response.status_code == 401
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.mark.asyncio
|
|
57
|
+
async def test_totp_setup_and_verify(client: AsyncClient):
|
|
58
|
+
"""TOTP setup returns a provisioning URI and accepts a current code."""
|
|
59
|
+
await client.post(
|
|
60
|
+
"/api/v1/auth/signup",
|
|
61
|
+
json={"email": "totp@example.com", "full_name": "TOTP User", "password": "StrongPassword!123"},
|
|
62
|
+
)
|
|
63
|
+
login = await client.post(
|
|
64
|
+
"/api/v1/auth/login", json={"email": "totp@example.com", "password": "StrongPassword!123"}
|
|
65
|
+
)
|
|
66
|
+
headers = {"Authorization": f"Bearer {login.json()['data']['access_token']}"}
|
|
67
|
+
setup = await client.post("/api/v1/auth/totp/setup", headers=headers)
|
|
68
|
+
assert setup.status_code == 200
|
|
69
|
+
secret = setup.json()["data"]["secret"]
|
|
70
|
+
verify = await client.post("/api/v1/auth/totp/verify", headers=headers, json={"code": pyotp.TOTP(secret).now()})
|
|
71
|
+
assert verify.status_code == 200
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.mark.asyncio
|
|
75
|
+
async def test_email_verification_activates_account(client: AsyncClient, monkeypatch):
|
|
76
|
+
"""Signup creates a token that activates the account when consumed."""
|
|
77
|
+
sent = {}
|
|
78
|
+
monkeypatch.setattr(auth_endpoint, "send_verification_email", lambda user, token: sent.setdefault("token", token))
|
|
79
|
+
response = await client.post(
|
|
80
|
+
"/api/v1/auth/signup",
|
|
81
|
+
json={"email": "verify@example.com", "full_name": "Verify", "password": "StrongPassword!123"},
|
|
82
|
+
)
|
|
83
|
+
assert response.status_code == 201
|
|
84
|
+
verified = await client.post("/api/v1/auth/verify-email", json={"token": sent["token"]})
|
|
85
|
+
assert verified.status_code == 200
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.mark.asyncio
|
|
89
|
+
async def test_forgot_and_reset_password(client: AsyncClient, monkeypatch):
|
|
90
|
+
"""Forgot-password creates a reset token that changes the login credential."""
|
|
91
|
+
sent = {}
|
|
92
|
+
monkeypatch.setattr(auth_endpoint, "send_reset_email", lambda user, token: sent.setdefault("token", token))
|
|
93
|
+
await client.post(
|
|
94
|
+
"/api/v1/auth/signup",
|
|
95
|
+
json={"email": "reset@example.com", "full_name": "Reset", "password": "StrongPassword!123"},
|
|
96
|
+
)
|
|
97
|
+
forgotten = await client.post("/api/v1/auth/forgot-password", json={"email": "reset@example.com"})
|
|
98
|
+
assert forgotten.status_code == 200
|
|
99
|
+
reset = await client.post(
|
|
100
|
+
"/api/v1/auth/reset-password", json={"token": sent["token"], "password": "NewStrongPassword!123"}
|
|
101
|
+
)
|
|
102
|
+
assert reset.status_code == 200
|
|
103
|
+
assert (
|
|
104
|
+
await client.post(
|
|
105
|
+
"/api/v1/auth/login", json={"email": "reset@example.com", "password": "NewStrongPassword!123"}
|
|
106
|
+
)
|
|
107
|
+
).status_code == 200
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@pytest.mark.asyncio
|
|
111
|
+
async def test_health_endpoint(client: AsyncClient):
|
|
112
|
+
"""The liveness endpoint is available without database access."""
|
|
113
|
+
response = await client.get("/health")
|
|
114
|
+
assert response.status_code == 200
|
|
115
|
+
assert response.json() == {"status": "ok"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Integration tests for product CRUD, ownership, and query controls."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from httpx import AsyncClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def product_token(client: AsyncClient) -> str:
|
|
8
|
+
"""Create a test user and return its access token."""
|
|
9
|
+
await client.post(
|
|
10
|
+
"/api/v1/auth/signup",
|
|
11
|
+
json={"email": "products@example.com", "full_name": "Products", "password": "StrongPassword!123"},
|
|
12
|
+
)
|
|
13
|
+
response = await client.post(
|
|
14
|
+
"/api/v1/auth/login", json={"email": "products@example.com", "password": "StrongPassword!123"}
|
|
15
|
+
)
|
|
16
|
+
return response.json()["data"]["access_token"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.mark.asyncio
|
|
20
|
+
async def test_product_crud(client: AsyncClient):
|
|
21
|
+
"""An owner can create, read, update, and delete a product."""
|
|
22
|
+
token = await product_token(client)
|
|
23
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
24
|
+
created = await client.post(
|
|
25
|
+
"/api/v1/products",
|
|
26
|
+
headers=headers,
|
|
27
|
+
json={"name": "Widget", "description": "A widget", "price": "12.50", "category": "tools"},
|
|
28
|
+
)
|
|
29
|
+
assert created.status_code == 201
|
|
30
|
+
product_id = created.json()["data"]["id"]
|
|
31
|
+
assert (await client.get(f"/api/v1/products/{product_id}")).status_code == 200
|
|
32
|
+
updated = await client.put(
|
|
33
|
+
f"/api/v1/products/{product_id}",
|
|
34
|
+
headers=headers,
|
|
35
|
+
json={"name": "Better Widget", "description": "Updated", "price": "15.00", "category": "tools"},
|
|
36
|
+
)
|
|
37
|
+
assert updated.status_code == 200
|
|
38
|
+
assert updated.json()["data"]["name"] == "Better Widget"
|
|
39
|
+
assert (await client.delete(f"/api/v1/products/{product_id}", headers=headers)).status_code == 200
|
|
40
|
+
assert (await client.get(f"/api/v1/products/{product_id}")).status_code == 404
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.asyncio
|
|
44
|
+
async def test_product_filter_sort_and_pagination(client: AsyncClient):
|
|
45
|
+
"""The collection endpoint applies category, price, sort, and page controls."""
|
|
46
|
+
token = await product_token(client)
|
|
47
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
48
|
+
for name, price in (("Cheap", "5.00"), ("Expensive", "50.00")):
|
|
49
|
+
response = await client.post(
|
|
50
|
+
"/api/v1/products", headers=headers, json={"name": name, "price": price, "category": "tools"}
|
|
51
|
+
)
|
|
52
|
+
assert response.status_code == 201
|
|
53
|
+
response = await client.get("/api/v1/products?page=1&page_size=1&category=tools&min_price=10&sort_by=price")
|
|
54
|
+
data = response.json()["data"]
|
|
55
|
+
assert data["total_count"] == 2
|
|
56
|
+
assert data["page"] == 1
|
|
57
|
+
assert data["limit"] == 1
|
|
58
|
+
assert data["items"][0]["name"] == "Expensive"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Integration tests for profile and administrator user management."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from httpx import AsyncClient
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
+
|
|
7
|
+
from app.core.security.password import hash_password
|
|
8
|
+
from app.models.domain.user import User
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def login(client: AsyncClient, email: str, password: str) -> str:
|
|
12
|
+
"""Return an access token from the credential login endpoint."""
|
|
13
|
+
response = await client.post("/api/v1/auth/login", json={"email": email, "password": password})
|
|
14
|
+
return response.json()["data"]["access_token"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.mark.asyncio
|
|
18
|
+
async def test_current_user_can_update_profile(client: AsyncClient):
|
|
19
|
+
"""Authenticated users can update their own display name."""
|
|
20
|
+
await client.post(
|
|
21
|
+
"/api/v1/auth/signup",
|
|
22
|
+
json={"email": "profile@example.com", "full_name": "Original", "password": "StrongPassword!123"},
|
|
23
|
+
)
|
|
24
|
+
token = await login(client, "profile@example.com", "StrongPassword!123")
|
|
25
|
+
response = await client.patch(
|
|
26
|
+
"/api/v1/users/me", headers={"Authorization": f"Bearer {token}"}, json={"full_name": "Updated"}
|
|
27
|
+
)
|
|
28
|
+
assert response.status_code == 200
|
|
29
|
+
assert response.json()["data"]["full_name"] == "Updated"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.mark.asyncio
|
|
33
|
+
async def test_non_admin_cannot_list_users(client: AsyncClient):
|
|
34
|
+
"""Regular accounts cannot access administrator user listings."""
|
|
35
|
+
await client.post(
|
|
36
|
+
"/api/v1/auth/signup",
|
|
37
|
+
json={"email": "member@example.com", "full_name": "Member", "password": "StrongPassword!123"},
|
|
38
|
+
)
|
|
39
|
+
token = await login(client, "member@example.com", "StrongPassword!123")
|
|
40
|
+
response = await client.get("/api/v1/users", headers={"Authorization": f"Bearer {token}"})
|
|
41
|
+
assert response.status_code == 401
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.asyncio
|
|
45
|
+
async def test_admin_can_list_and_update_users(client: AsyncClient, database_session: AsyncSession):
|
|
46
|
+
"""Administrators can list users and change a user's active state."""
|
|
47
|
+
admin = User(
|
|
48
|
+
email="admin@example.com",
|
|
49
|
+
full_name="Admin",
|
|
50
|
+
password_hash=hash_password("AdminPassword!123"),
|
|
51
|
+
is_admin=True,
|
|
52
|
+
is_verified=True,
|
|
53
|
+
)
|
|
54
|
+
database_session.add(admin)
|
|
55
|
+
await database_session.commit()
|
|
56
|
+
await client.post(
|
|
57
|
+
"/api/v1/auth/signup",
|
|
58
|
+
json={"email": "managed@example.com", "full_name": "Managed", "password": "StrongPassword!123"},
|
|
59
|
+
)
|
|
60
|
+
token = await login(client, "admin@example.com", "AdminPassword!123")
|
|
61
|
+
listing = await client.get("/api/v1/users", headers={"Authorization": f"Bearer {token}"})
|
|
62
|
+
assert listing.status_code == 200
|
|
63
|
+
managed = next(item for item in listing.json()["data"] if item["email"] == "managed@example.com")
|
|
64
|
+
updated = await client.patch(
|
|
65
|
+
f"/api/v1/users/{managed['id']}", headers={"Authorization": f"Bearer {token}"}, json={"is_active": False}
|
|
66
|
+
)
|
|
67
|
+
assert updated.status_code == 200
|
|
68
|
+
assert updated.json()["data"]["is_active"] is False
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Unit tests for authentication and credential primitives."""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime, timedelta
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from app.core.security.jwt import create_token, decode_token
|
|
9
|
+
from app.core.security.password import hash_password, verify_password
|
|
10
|
+
from app.services.auth_service import hash_refresh_token, issue_tokens
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_refresh_hash_is_deterministic_and_not_plaintext():
|
|
14
|
+
"""The database representation is fixed-length SHA-256, never the bearer value."""
|
|
15
|
+
assert hash_refresh_token("secret") == hash_refresh_token("secret")
|
|
16
|
+
assert hash_refresh_token("secret") != "secret"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_issue_tokens_returns_matching_refresh_digest():
|
|
20
|
+
"""Token issuance returns independently usable values and a matching digest."""
|
|
21
|
+
access, refresh, digest, expiry = issue_tokens(uuid4())
|
|
22
|
+
assert access and refresh and digest and expiry > datetime.now(UTC)
|
|
23
|
+
assert digest == hash_refresh_token(refresh)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_access_token_contains_subject_and_type():
|
|
27
|
+
"""JWT claims identify the subject and prevent refresh-token confusion."""
|
|
28
|
+
subject = uuid4()
|
|
29
|
+
token = create_token(subject, "access", timedelta(minutes=5))
|
|
30
|
+
claims = decode_token(token)
|
|
31
|
+
assert claims["sub"] == str(subject)
|
|
32
|
+
assert claims["type"] == "access"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_wrong_token_type_is_rejected():
|
|
36
|
+
"""A token issued for one purpose cannot be used for another purpose."""
|
|
37
|
+
token = create_token(uuid4(), "refresh", timedelta(minutes=5))
|
|
38
|
+
with pytest.raises(ValueError):
|
|
39
|
+
decode_token(token)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_password_hash_is_salted_and_verifiable():
|
|
43
|
+
"""Two hashes differ because bcrypt salts each password independently."""
|
|
44
|
+
first = hash_password("StrongPassword!123")
|
|
45
|
+
second = hash_password("StrongPassword!123")
|
|
46
|
+
assert first != second
|
|
47
|
+
assert verify_password("StrongPassword!123", first)
|
|
48
|
+
assert not verify_password("wrong", first)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Unit tests for product service business operations."""
|
|
2
|
+
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from app.models.domain.product import Product
|
|
9
|
+
from app.models.schemas.product import ProductCreate
|
|
10
|
+
from app.services.product_service import create_product, update_product
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.mark.asyncio
|
|
14
|
+
async def test_create_product_persists_payload():
|
|
15
|
+
"""Product creation maps validated schema fields to the model."""
|
|
16
|
+
session = AsyncMock()
|
|
17
|
+
session.add = MagicMock()
|
|
18
|
+
session.refresh = AsyncMock()
|
|
19
|
+
product = await create_product(session, ProductCreate(name="Widget", price=Decimal("2.50")))
|
|
20
|
+
assert product.name == "Widget"
|
|
21
|
+
assert product.price == Decimal("2.50")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.mark.asyncio
|
|
25
|
+
async def test_update_product_replaces_editable_fields():
|
|
26
|
+
"""Product updates modify name, category, description, and price."""
|
|
27
|
+
session = AsyncMock()
|
|
28
|
+
product = Product(name="Old", description="old", price=Decimal("1.00"))
|
|
29
|
+
await update_product(
|
|
30
|
+
session, product, ProductCreate(name="New", description="new", price=Decimal("2.00"), category="tools")
|
|
31
|
+
)
|
|
32
|
+
assert product.name == "New"
|
|
33
|
+
assert product.category == "tools"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Unit tests for user service business operations."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from app.models.domain.user import User
|
|
8
|
+
from app.models.schemas.user import UserCreate, UserUpdate
|
|
9
|
+
from app.services.user_service import create_user, update_user
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.mark.asyncio
|
|
13
|
+
async def test_create_user_hashes_password():
|
|
14
|
+
"""User creation persists a bcrypt hash rather than plaintext."""
|
|
15
|
+
session = AsyncMock()
|
|
16
|
+
session.add = MagicMock()
|
|
17
|
+
session.refresh = AsyncMock()
|
|
18
|
+
user = await create_user(
|
|
19
|
+
session, UserCreate(email="new@example.com", full_name="New", password="StrongPassword!123")
|
|
20
|
+
)
|
|
21
|
+
assert user.password_hash != "StrongPassword!123"
|
|
22
|
+
session.commit.assert_awaited_once()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.mark.asyncio
|
|
26
|
+
async def test_update_user_changes_only_allowed_profile_field():
|
|
27
|
+
"""Profile updates do not alter role or credential fields."""
|
|
28
|
+
session = AsyncMock()
|
|
29
|
+
user = User(email="user@example.com", full_name="Before", password_hash="hash", is_admin=False)
|
|
30
|
+
await update_user(session, user, UserUpdate(full_name="After"))
|
|
31
|
+
assert user.full_name == "After"
|
|
32
|
+
assert user.is_admin is False
|
app/utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Small pure helpers with no business or HTTP dependencies."""
|
app/utils/date_utils.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Timezone-aware date formatting helpers."""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def utc_iso(value: datetime | None = None) -> str:
|
|
7
|
+
"""Format a datetime as an explicit UTC ISO-8601 string."""
|
|
8
|
+
return (value or datetime.now(UTC)).astimezone(UTC).isoformat()
|
app/utils/file_utils.py
ADDED
app/utils/logger.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Central Loguru configuration with rotation and retention.
|
|
2
|
+
|
|
3
|
+
Applications may replace the sink in one place to integrate with a managed
|
|
4
|
+
logging platform. Never log passwords, bearer tokens, or reset links.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
logger.remove()
|
|
10
|
+
logger.add("logs/app.log", rotation="10 MB", retention="30 days", serialize=True, enqueue=True)
|
|
11
|
+
logger.add(lambda message: print(message, end=""), level="INFO")
|
app/utils/validators.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Reusable validation functions for values not covered by Pydantic fields."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def is_strong_password(password: str) -> bool:
|
|
7
|
+
"""Require upper, lower, digit, symbol, and a twelve-character minimum."""
|
|
8
|
+
return len(password) >= 12 and bool(
|
|
9
|
+
re.search(r"[A-Z]", password)
|
|
10
|
+
and re.search(r"[a-z]", password)
|
|
11
|
+
and re.search(r"\d", password)
|
|
12
|
+
and re.search(r"[^A-Za-z0-9]", password)
|
|
13
|
+
)
|
|
Binary file
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
app/__init__.py,sha256=ITWqSHnty47G8bzwU0xhOf4DE9qvmDGAUi9njoX866k,318
|
|
2
|
+
app/main.py,sha256=QRBOaROPI8EpvnQQkkEG4EaMkTivryiSYh1xnX0YSpg,1926
|
|
3
|
+
app/api/__init__.py,sha256=KodSsSkjyFZ1wrOEYuXH_OOhOFwZzZ1GdHWLXAzgNe8,26
|
|
4
|
+
app/api/deps/__init__.py,sha256=Wcx5dlMLPfRRMqiG2rZCttzWAvJ9pNgICeJklLZ_3qU,37
|
|
5
|
+
app/api/deps/auth.py,sha256=m0Q00JSNl-q2NPG54Kx-copSLo-fT2wCqkXJWfqr_Hc,1480
|
|
6
|
+
app/api/deps/pagination.py,sha256=P6gyIBF-nSajbwsb8PoimH-uUQQkyAgaWbmAKvAPjOs,308
|
|
7
|
+
app/api/v1/__init__.py,sha256=NSwukK-n6wMt-5QZwyZNkvu0jbKtX9Bk1vHEH8_Nkvk,33
|
|
8
|
+
app/api/v1/endpoints/__init__.py,sha256=KvQACxGWS4udvDyAFYVIfhbrxr3CXjZAwUIR2xFLlnk,36
|
|
9
|
+
app/api/v1/endpoints/auth.py,sha256=B4WRk6S2ajVL33NdVW8UqB8e9lIfNpZmL2rXmbWhCxk,11983
|
|
10
|
+
app/api/v1/endpoints/dashboard.py,sha256=tWipV9U7whKblF0ul1-rLahI9MLH3CRSo-6JEVsnukA,1093
|
|
11
|
+
app/api/v1/endpoints/products.py,sha256=6xJhz9_TrV6QtE1PvEol-b3KqAjQqW4WBut4LICC5XY,4277
|
|
12
|
+
app/api/v1/endpoints/users.py,sha256=KgLgCtdfCc_JNhyO52pvhKaNopAVkP2I7vNGKVDgj_A,3417
|
|
13
|
+
app/api/v1/routers/__init__.py,sha256=NqHDFG1hmdTh0wH_XaKt9D32m7s3iOe604F1kLndS5U,37
|
|
14
|
+
app/api/v1/routers/api_router.py,sha256=zO__zUFJSgfgNCV726dZZm1Or-i8JpAx_zuoVu9oAYg,342
|
|
15
|
+
app/core/__init__.py,sha256=FIk5KtRzI5uDeCmCFxr-z_ldX5hkaiu8KBhqoZMZI1k,80
|
|
16
|
+
app/core/config/__init__.py,sha256=aOGV1j12RPh2DgAweCVAMbtqDe_dhkbkATgWOJ2zK18,50
|
|
17
|
+
app/core/config/database.py,sha256=_VyZ5pu0Ea4hmbGDmQ9yaD7pmhA9TwS8C7qXMZ7eQOY,553
|
|
18
|
+
app/core/config/settings.py,sha256=auo_aT8khtXQQ9kWTSRtLkhMFzkEi9dXDn-lUyidvGw,4218
|
|
19
|
+
app/core/exceptions/__init__.py,sha256=seTrkfNqRMfcWUr6oWzo73Ccr02CtcfuwnC9-vZNMCg,42
|
|
20
|
+
app/core/exceptions/base_exception.py,sha256=FIIpbgngcUv10cEV4j50HdM_UmycV-QFt19FFkff1yU,404
|
|
21
|
+
app/core/exceptions/custom_exceptions.py,sha256=L3TPvdZXtr0OwpWZ7ligcK3B5z6XTK0H_K_gjTq1fmY,541
|
|
22
|
+
app/core/middleware/__init__.py,sha256=N1ezjt2INhnh5nIdG46hQUHU7sX-2Q6ScOeSM2Yh-WI,57
|
|
23
|
+
app/core/middleware/cors.py,sha256=-9K0FQWunNPkwNekIU4drdroFd6PlmUS-ITTc9aiCPo,216
|
|
24
|
+
app/core/middleware/error_handler.py,sha256=Y2D6b1pDBZDNgwgmq8BeHZ4qiCznGbpcfcYo7rzV7jc,820
|
|
25
|
+
app/core/middleware/logging.py,sha256=5wokekqERtb5_7gk6Pc2BQiT_RtuydEviofCTIFc6uc,1167
|
|
26
|
+
app/core/middleware/rate_limit.py,sha256=GOJP2XfeZoVDZjsdZQAxhs2_Lslb50BW1DSja8jrcbU,927
|
|
27
|
+
app/core/security/__init__.py,sha256=uMwPaSb7JxuFAzF5qRBOMBkC_18hkH21WcMYgfOEUdE,64
|
|
28
|
+
app/core/security/auth.py,sha256=Cy5Z4neOPRO1ByGdWziM0aNisI2xLqOdbxPymF7NFEs,341
|
|
29
|
+
app/core/security/jwt.py,sha256=M6Apv0vu8J9I0H6refBp6ojQs5cUF82fyTmfOLOuZhQ,1400
|
|
30
|
+
app/core/security/oauth.py,sha256=1sYkl5l4KI8lL0nIVY5pr3WKWARSkfo0_mfgCflnxcs,325
|
|
31
|
+
app/core/security/password.py,sha256=xyPf6YyYXw1v3AGlCz5UG_tFpvPy4SHIE8-AbXJmHjY,710
|
|
32
|
+
app/core/security/totp.py,sha256=x0iVHA5Y8hM5zdNggVQD9pX_1U80eQySdCl98ntkTV8,402
|
|
33
|
+
app/db/__init__.py,sha256=-5YRLYVpvPbEWDjUxyBKu5zEwikMV4StqpEskMTP3OM,46
|
|
34
|
+
app/db/base.py,sha256=lVrpob0gI-DFBGYJchJcEC5vOjZ8JdNMrIUciug7fI4,204
|
|
35
|
+
app/db/session.py,sha256=Gd2VyuJu1-p2fXlO558U3zNDij5G7Ydm2t3NnaSFfJs,353
|
|
36
|
+
app/db/migrations/env.py,sha256=93BySoir11StMvMWra3Jh0cjCJmBgtRlKJ5TwKGXq6Y,1427
|
|
37
|
+
app/db/migrations/versions/97ed1bc05f4a_complete_product_and_totp_fields.py,sha256=54v3xWqwb2jOZvLSPIXIK2xyVCiT9b9vEmGzcKb-0e0,2240
|
|
38
|
+
app/db/migrations/versions/f8b632aa11da_initial_migration.py,sha256=bSLNgP0x8wwGrJkT75XRGSNkV_-6-VtMf0j88G09U0I,5385
|
|
39
|
+
app/models/__init__.py,sha256=brUa_brdi2sWbVh9A7nkEVTGtcj1Sv3KLtWneEeApEE,518
|
|
40
|
+
app/models/domain/__init__.py,sha256=it9kNQEgkbXUGmrwAP92wLpGqaePowae0KWTSWU62W0,41
|
|
41
|
+
app/models/domain/base.py,sha256=j8ynMqabOz5N_1ykfGn2PDnCaNpju4EMjFm3_3ygXrE,724
|
|
42
|
+
app/models/domain/email_verification.py,sha256=c6mw1Pp7l3PlVX7SQzalLWG0X_ng7Az9C7dE_tg8Tc4,938
|
|
43
|
+
app/models/domain/password_reset.py,sha256=FHsH87Nrcd6CI_N1ABq_YoQHdI0SStDDEi8HGfJz53I,949
|
|
44
|
+
app/models/domain/product.py,sha256=-fOujqws6Qllcc3h7LVMhP2ZAfch1rnRgwNdSJmeuFE,831
|
|
45
|
+
app/models/domain/refresh_token.py,sha256=Wihy0SRA3WI7RO2w1v9JG6C2TEYd_yn9nfnSgDf8irg,983
|
|
46
|
+
app/models/domain/totp_secret.py,sha256=XOABus9AXJ9Ddn0XYBX5ZincbXbF9Bf3VaVJ9fbGoME,1669
|
|
47
|
+
app/models/domain/user.py,sha256=DvDHGCIuPGXejDsyOzAF3Z5SEOjyXDOkJnTyOwdvR3U,1625
|
|
48
|
+
app/models/schemas/__init__.py,sha256=GSkoAMgiZLeQKOAQwaBDSvLwValEUwJqBU6gPLffx_o,45
|
|
49
|
+
app/models/schemas/auth.py,sha256=-2NQ5SqeERoyYpYE2FSgcrRIh43K5n4gN5eMNCG5V7Q,738
|
|
50
|
+
app/models/schemas/password.py,sha256=BFApbSmUqzHRTm1yPkrWBe4PmH8qUv13Wef8Op7SV1o,374
|
|
51
|
+
app/models/schemas/product.py,sha256=GSZB71Xo0cgpvorXma02VjvOlk6cUPkWDR25hrOPXbE,775
|
|
52
|
+
app/models/schemas/response.py,sha256=Ypfw4j90WNxi_WqqIuFyE9VE9N-yo-Dsr7t5Oo8y2AQ,473
|
|
53
|
+
app/models/schemas/token.py,sha256=drHEH2QErm2vfiHEAW2X8giVfhuLoGf5SvtbWNF7KEQ,358
|
|
54
|
+
app/models/schemas/totp.py,sha256=RfdV2lt0XhoYkjnD93na-t1WzXIScuL1QF_RYjKmmzE,374
|
|
55
|
+
app/models/schemas/user.py,sha256=fj5rnYiAtBsMOE78Dma-IVRM8jbFBYVc-yrzuc_E24U,922
|
|
56
|
+
app/repositories/__init__.py,sha256=jE_Lwi9eoISFCA_NW2lSyqBD_SfuE5vU6kxNvQ0jrg4,58
|
|
57
|
+
app/repositories/email_verification_repository.py,sha256=NMs3OsJHMcmJLb-B8ajfz80VGg3LFMQK-CySMKpZFlQ,1294
|
|
58
|
+
app/repositories/password_reset_repository.py,sha256=UhmyTDz6vABhZ76epZn87zgOZ1EgDfQ1iBO_R9GEWEI,1121
|
|
59
|
+
app/repositories/product_repository.py,sha256=WJkvhnkvtSDSbRAXiNWGmlJpD4ap-ZvnDrBBZhWPYU8,1474
|
|
60
|
+
app/repositories/refresh_token_repository.py,sha256=mKUTXEtppz1Jfo5Hz6LzI8Fvi44Hb2BuRXffjZG7tek,1407
|
|
61
|
+
app/repositories/totp_secret_repository.py,sha256=g3K2SfTEfcO7D9uvh3xbPYsX7DVxCPsqQBa2MwK7UBc,776
|
|
62
|
+
app/repositories/user_repository.py,sha256=Sc9kPj-3zcCXjeGQw1oDxyYOgdLV4vjm-5n-nXaGxE4,1479
|
|
63
|
+
app/repositories/base/__init__.py,sha256=SRqlheoVnRx-7TMgwEONcB9e97cSGW_3GPN2MIGHs5I,40
|
|
64
|
+
app/repositories/base/base_repository.py,sha256=5eHZXDvXf3AndG7ozNNPT1pKp60Y5-cvpgpDyQVoWD4,2025
|
|
65
|
+
app/services/__init__.py,sha256=g8HzLkrQpvwcDJWdfu4fnAWOCMZqRnvCXvDaKoO07U4,74
|
|
66
|
+
app/services/auth_service.py,sha256=gdSPUOcMXk8nkXX7rrk8ZSPiD36m7TbHLUCRhn5Yuf8,1235
|
|
67
|
+
app/services/email_service.py,sha256=hbZ2qqji51iTn8SWIeHLskCNvKEQlt_ITk_WnmOgocw,1391
|
|
68
|
+
app/services/product_service.py,sha256=TshhMqseRUU5GLRF738R3qXeEhkO1BjJ1p6Y6zIJr60,859
|
|
69
|
+
app/services/social_auth_service.py,sha256=sbuaYcLRb819cekjJG3767QKCwTjMMDezaIBitTXkNQ,2116
|
|
70
|
+
app/services/totp_service.py,sha256=WTqIg-WL6iv6RQTRWOtozWJvV_x1FVBPFqK1KASBa_I,392
|
|
71
|
+
app/services/user_service.py,sha256=V63V2fhL86fryLY2g345wAgYXUy_EA8nLr9CGgCWC20,971
|
|
72
|
+
app/services/base/__init__.py,sha256=xmy_RDDZmTxrwMTAZoxcgyrvkV6Zn7QSajbQmkdv1No,37
|
|
73
|
+
app/services/base/base_service.py,sha256=kNfET76erQSZyPh93LavpwWYJ4UAcTRHDtwF-jHmEI8,673
|
|
74
|
+
app/tests/__init__.py,sha256=RMe_6_k55X9JooxiaFDBfi6hc9BI-y1EJHRW6mWdP40,54
|
|
75
|
+
app/tests/conftest.py,sha256=INyg0Phm-FIkTfYEezKMW6m2Tl8XdhUdQlZETLaBdUg,1435
|
|
76
|
+
app/tests/integration/test_auth_api.py,sha256=Hoc8zRLsu1h53tdchiDZxIOQv_q0FrPrJeREfcbQEqg,5192
|
|
77
|
+
app/tests/integration/test_product_api.py,sha256=3vCkxuzbDpuGIVkAWIc5M4uVVeWEqs0QX1zLiRxbg0E,2563
|
|
78
|
+
app/tests/integration/test_user_api.py,sha256=lwcZ4krqxAd9rdLncThM8CeyqkAv8Wm3SllnQQvR09Q,2976
|
|
79
|
+
app/tests/unit/test_auth_service.py,sha256=QhAL9YQ9MrYJPYoNpIPB7KHK6uqjTw7MJpF06r4VnBo,1937
|
|
80
|
+
app/tests/unit/test_product_service.py,sha256=secvzUcPclYXtCyOinftfmQf0MdIVlPHDS_axjACsNg,1236
|
|
81
|
+
app/tests/unit/test_user_service.py,sha256=M7SWq7hy5_FwffBR217A86vJUD4AR4WJqjl2B3d9TNs,1193
|
|
82
|
+
app/utils/__init__.py,sha256=o8f2P1oHPwNyJPjCNyEG16TR3y4D0Ivq55GBARsp-Jw,64
|
|
83
|
+
app/utils/date_utils.py,sha256=JURJXd7IUpgBE2_NyW-jp4UapfAXaI923UAWOw4sHkE,275
|
|
84
|
+
app/utils/file_utils.py,sha256=jKJ2UMvJjh9s-10Z4JizJR79Ee7tzVdjFjgC6ukw-Dc,264
|
|
85
|
+
app/utils/logger.py,sha256=DGqMTLSWP8y7ST3-TjMtfKYPX2HPmzv_7Mlr0_zX2z4,427
|
|
86
|
+
app/utils/validators.py,sha256=6S-olI1YEar1TXsrDIdKxuUjSimXxPjA5lABZ0p_6gk,446
|
|
87
|
+
hinbert_fastapi-0.1.0.dist-info/licenses/LICENSE,sha256=u2u91B7zuvQyx2ACdQULyh0YMb_yr2SjiqIR4gLEGIA,1097
|
|
88
|
+
scripts/__init__.py,sha256=zzoqX63YF4xYySgy4TPgQkNAV_FeE68kcIv7RJwTL6c,78
|
|
89
|
+
scripts/create_admin.py,sha256=JG3V1RNIgXM7k1GxXkPxkO5EsC-J40NAUouAU0W91Aw,1468
|
|
90
|
+
scripts/run_migrations.py,sha256=Fy_LN5xtNK0zsRE05BWbUhIjXIxJQzQlEmjwsRLZS3I,312
|
|
91
|
+
scripts/seed_data.py,sha256=Pgr0Z9aUFjrtkvL1i_1f4a7B2FqdJ19XRf2-aDg2K7o,1665
|
|
92
|
+
hinbert_fastapi-0.1.0.dist-info/METADATA,sha256=RfTUkP1vnOjlxgrnMgvtThl4zb2D5An50d4GQP-0-UU,7537
|
|
93
|
+
hinbert_fastapi-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
94
|
+
hinbert_fastapi-0.1.0.dist-info/entry_points.txt,sha256=db2SvdTODpwyBDNX-f9TFW9U-XzPJH0Bizcr86Wz5iU,183
|
|
95
|
+
hinbert_fastapi-0.1.0.dist-info/top_level.txt,sha256=0TlswQcp1e2_wFmkaFYptXRuV0X6QpkLpMINHMa_iPI,12
|
|
96
|
+
hinbert_fastapi-0.1.0.dist-info/RECORD,,
|