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.
Files changed (96) hide show
  1. app/__init__.py +8 -0
  2. app/api/__init__.py +1 -0
  3. app/api/deps/__init__.py +1 -0
  4. app/api/deps/auth.py +40 -0
  5. app/api/deps/pagination.py +8 -0
  6. app/api/v1/__init__.py +1 -0
  7. app/api/v1/endpoints/__init__.py +1 -0
  8. app/api/v1/endpoints/auth.py +255 -0
  9. app/api/v1/endpoints/dashboard.py +23 -0
  10. app/api/v1/endpoints/products.py +102 -0
  11. app/api/v1/endpoints/users.py +89 -0
  12. app/api/v1/routers/__init__.py +1 -0
  13. app/api/v1/routers/api_router.py +11 -0
  14. app/core/__init__.py +1 -0
  15. app/core/config/__init__.py +1 -0
  16. app/core/config/database.py +13 -0
  17. app/core/config/settings.py +83 -0
  18. app/core/exceptions/__init__.py +1 -0
  19. app/core/exceptions/base_exception.py +9 -0
  20. app/core/exceptions/custom_exceptions.py +17 -0
  21. app/core/middleware/__init__.py +1 -0
  22. app/core/middleware/cors.py +5 -0
  23. app/core/middleware/error_handler.py +23 -0
  24. app/core/middleware/logging.py +29 -0
  25. app/core/middleware/rate_limit.py +27 -0
  26. app/core/security/__init__.py +1 -0
  27. app/core/security/auth.py +8 -0
  28. app/core/security/jwt.py +33 -0
  29. app/core/security/oauth.py +8 -0
  30. app/core/security/password.py +18 -0
  31. app/core/security/totp.py +13 -0
  32. app/db/__init__.py +1 -0
  33. app/db/base.py +7 -0
  34. app/db/migrations/env.py +39 -0
  35. app/db/migrations/versions/97ed1bc05f4a_complete_product_and_totp_fields.py +49 -0
  36. app/db/migrations/versions/f8b632aa11da_initial_migration.py +113 -0
  37. app/db/session.py +11 -0
  38. app/main.py +50 -0
  39. app/models/__init__.py +10 -0
  40. app/models/domain/__init__.py +1 -0
  41. app/models/domain/base.py +20 -0
  42. app/models/domain/email_verification.py +24 -0
  43. app/models/domain/password_reset.py +24 -0
  44. app/models/domain/product.py +22 -0
  45. app/models/domain/refresh_token.py +24 -0
  46. app/models/domain/totp_secret.py +44 -0
  47. app/models/domain/user.py +34 -0
  48. app/models/schemas/__init__.py +1 -0
  49. app/models/schemas/auth.py +30 -0
  50. app/models/schemas/password.py +16 -0
  51. app/models/schemas/product.py +31 -0
  52. app/models/schemas/response.py +17 -0
  53. app/models/schemas/token.py +17 -0
  54. app/models/schemas/totp.py +16 -0
  55. app/models/schemas/user.py +37 -0
  56. app/repositories/__init__.py +1 -0
  57. app/repositories/base/__init__.py +1 -0
  58. app/repositories/base/base_repository.py +51 -0
  59. app/repositories/email_verification_repository.py +37 -0
  60. app/repositories/password_reset_repository.py +33 -0
  61. app/repositories/product_repository.py +40 -0
  62. app/repositories/refresh_token_repository.py +40 -0
  63. app/repositories/totp_secret_repository.py +23 -0
  64. app/repositories/user_repository.py +44 -0
  65. app/services/__init__.py +1 -0
  66. app/services/auth_service.py +32 -0
  67. app/services/base/__init__.py +1 -0
  68. app/services/base/base_service.py +21 -0
  69. app/services/email_service.py +32 -0
  70. app/services/product_service.py +24 -0
  71. app/services/social_auth_service.py +47 -0
  72. app/services/totp_service.py +13 -0
  73. app/services/user_service.py +27 -0
  74. app/tests/__init__.py +1 -0
  75. app/tests/conftest.py +36 -0
  76. app/tests/integration/test_auth_api.py +115 -0
  77. app/tests/integration/test_product_api.py +58 -0
  78. app/tests/integration/test_user_api.py +68 -0
  79. app/tests/unit/test_auth_service.py +48 -0
  80. app/tests/unit/test_product_service.py +33 -0
  81. app/tests/unit/test_user_service.py +32 -0
  82. app/utils/__init__.py +1 -0
  83. app/utils/date_utils.py +8 -0
  84. app/utils/file_utils.py +8 -0
  85. app/utils/logger.py +11 -0
  86. app/utils/validators.py +13 -0
  87. hinbert_fastapi-0.1.0.dist-info/METADATA +0 -0
  88. hinbert_fastapi-0.1.0.dist-info/RECORD +96 -0
  89. hinbert_fastapi-0.1.0.dist-info/WHEEL +5 -0
  90. hinbert_fastapi-0.1.0.dist-info/entry_points.txt +5 -0
  91. hinbert_fastapi-0.1.0.dist-info/licenses/LICENSE +21 -0
  92. hinbert_fastapi-0.1.0.dist-info/top_level.txt +2 -0
  93. scripts/__init__.py +1 -0
  94. scripts/create_admin.py +41 -0
  95. scripts/run_migrations.py +13 -0
  96. 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."""
@@ -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()
@@ -0,0 +1,8 @@
1
+ """Safe file helper boundaries for future object-storage integration."""
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def safe_filename(filename: str) -> str:
7
+ """Strip path components so user input cannot select arbitrary directories."""
8
+ return Path(filename).name
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")
@@ -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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+