feat: add tests command

This commit is contained in:
2026-05-09 00:02:33 +03:00
parent 152a8ed6ac
commit 22f27fa524
10 changed files with 197 additions and 25 deletions

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Protocol, runtime_checkable
from src.application.abstractions.repositories import IOrderRepository,IPaymentRepository
from src.application.abstractions.repositories import IOrderRepository,IPaymentRepository,IUserRepository
@runtime_checkable
@@ -17,3 +17,6 @@ class IUnitOfWork(Protocol):
@property
def payment_repository(self) -> IPaymentRepository: ...
@property
def user_repository(self) -> IUserRepository: ...

View File

@@ -1,2 +1,3 @@
from src.application.abstractions.repositories.i_order_repository import IOrderRepository
from src.application.abstractions.repositories.i_payment_repository import IPaymentRepository
from src.application.abstractions.repositories.i_payment_repository import IPaymentRepository
from src.application.abstractions.repositories.i_user_repository import IUserRepository

View File

@@ -0,0 +1,9 @@
from abc import ABC,abstractmethod
from src.application.domain.entities.user import UserEntity
class IUserRepository(ABC):
@abstractmethod
async def get(self,user_id:str) -> UserEntity|None:
raise NotImplementedError

View File

@@ -1,2 +1,3 @@
from src.application.commands.create_order_command import CreateOrderCommand
from src.application.commands.create_payment_command import CreatePaymentCommand
from src.application.commands.create_payment_command import CreatePaymentCommand
from src.application.commands.create_payment_cloudkassir_command import CreatePaymentCloudkassirCommand

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
from decimal import Decimal
from ulid import ULID
from src.application.abstractions import IUnitOfWork
from src.application.contracts import IReceipt
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.database.decorators import transactional
from src.presentation.schemas.itpay_payment_models import ItpayPaymentData
class CreatePaymentCloudkassirCommand:
def __init__(self, *, unit_of_work: IUnitOfWork, receipt: IReceipt):
self._unit_of_work = unit_of_work
self._receipt = receipt
@transactional
async def __call__(self, payment: ItpayPaymentData) -> None:
if str(payment.status).strip().lower() != 'completed':
return
metadata = payment.metadata or {}
order_id = str(metadata.get('order_id') or '')
user_id = str(metadata.get('user_id') or '')
if not order_id:
raise ApplicationException(status_code=400, message='Itpay webhook metadata missing order_id')
if not user_id:
raise ApplicationException(status_code=400, message='Itpay webhook metadata missing user_id')
await self._unit_of_work.payment_repository.create_completed(
user_id=user_id,
order_id=order_id,
itpay_payment_id=str(payment.id),
itpay_paid_amount=str(payment.amount) if payment.amount is not None else None,
transaction_id=str(payment.transaction_id) if payment.transaction_id is not None else None,
paid_at=str(payment.paid) if payment.paid is not None else None,
expired_date=str(payment.expired_date) if payment.expired_date is not None else None,
)
user = await self._unit_of_work.user_repository.get(user_id)
if user is None:
raise ApplicationException(status_code=404, message='User not found')
email = str(user.email or '').strip()
if not email:
raise ApplicationException(status_code=400, message='User email missing')
phone_raw = (user.phone or '').strip()
phone = phone_raw if phone_raw else None
total_amount: Decimal | None = None
if payment.amount is not None:
total_amount = Decimal(str(payment.amount)).quantize(Decimal('0.01'))
if total_amount is None:
raw_amt = metadata.get('amount')
if raw_amt is not None:
total_amount = Decimal(str(raw_amt)).quantize(Decimal('0.01'))
if total_amount is None:
raise ApplicationException(status_code=400, message='Itpay webhook payment amount missing for receipt')
raw_sf = metadata.get('service_fee')
if raw_sf is None:
raise ApplicationException(status_code=400, message='Itpay webhook metadata missing service_fee for receipt')
service_fee = Decimal(str(raw_sf)).quantize(Decimal('0.01'))
principal_amount = (total_amount - service_fee).quantize(Decimal('0.01'))
if principal_amount < 0:
raise ApplicationException(status_code=400, message='Invalid receipt amounts: principal negative')
await self._receipt.create_receipt(
order_id=order_id,
user_id=user_id,
email=email,
total_amount=total_amount,
principal_amount=principal_amount,
service_fee=service_fee,
phone=phone,
request_id=str(ULID()),
)

View File

@@ -270,6 +270,30 @@ class Settings(BaseSettings):
data['ITPAY_PUBLIC_ID'] = str(public_id).strip()
data['ITPAY_API_SECRET'] = str(api_secret).strip()
ck_public = data.get('CLOUD_KASSIR_PUBLIC_ID') or os.getenv('CLOUD_KASSIR_PUBLIC_ID')
ck_secret = data.get('CLOUD_KASSIR_API_SECRET') or os.getenv('CLOUD_KASSIR_API_SECRET')
if ck_public is not None and str(ck_public).strip() and ck_secret is not None and str(ck_secret).strip():
data['CLOUD_KASSIR_PUBLIC_ID'] = str(ck_public).strip()
data['CLOUD_KASSIR_API_SECRET'] = str(ck_secret).strip()
else:
cloudkassir = read_secret_optional('cloudkassir')
if cloudkassir:
ck_ci = {str(k).lower(): v for k, v in cloudkassir.items()}
public_id_ck = ck_ci.get('public_id')
api_secret_ck = ck_ci.get('api_secret')
if api_secret_ck is None:
api_secret_ck = ck_ci.get('secret')
if public_id_ck is not None and str(public_id_ck).strip():
data['CLOUD_KASSIR_PUBLIC_ID'] = str(public_id_ck).strip()
if api_secret_ck is not None and str(api_secret_ck).strip():
data['CLOUD_KASSIR_API_SECRET'] = str(api_secret_ck).strip()
inn_ck = ck_ci.get('inn')
if inn_ck is not None and str(inn_ck).strip():
data['CLOUD_KASSIR_INN'] = str(inn_ck).strip()
base_ck = ck_ci.get('api_base_url')
if base_ck is not None and str(base_ck).strip():
data['CLOUD_KASSIR_API_BASE_URL'] = str(base_ck).strip()
return data
@property

View File

@@ -0,0 +1,50 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.application.abstractions.repositories.i_user_repository import IUserRepository
from src.application.contracts import ILogger
from src.application.domain.entities.user import UserEntity
from src.infrastructure.database.models.user import UserModel
class UserRepository(IUserRepository):
def __init__(self,session:AsyncSession,logger:ILogger):
self._session=session
self._logger=logger
@staticmethod
def _to_entity(model:UserModel) -> UserEntity:
return UserEntity(
id=model.id,
email=model.email,
password_hash=model.password_hash,
first_name=model.first_name,
middle_name=model.middle_name,
last_name=model.last_name,
birth_date=model.birth_date,
crypto_wallet=model.crypto_wallet,
phone=model.phone,
bik=model.bik,
account_number=model.account_number,
card_number=model.card_number,
inn=model.inn,
kyc_verified=model.kyc_verified,
is_deleted=model.is_deleted,
created_at=model.created_at,
updated_at=model.updated_at,
kyc_verified_at=model.kyc_verified_at,
)
async def get(self,user_id:str) -> UserEntity|None:
stmt=(
select(UserModel)
.where(UserModel.id==user_id)
.where(UserModel.is_deleted.is_(False))
)
model=await self._session.scalar(stmt)
if model is None:
return None
return self._to_entity(model)

View File

@@ -1,10 +1,10 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.application.abstractions import IUnitOfWork
from src.application.abstractions.repositories import IOrderRepository,IPaymentRepository
from src.application.abstractions.repositories import IOrderRepository,IPaymentRepository,IUserRepository
from src.application.contracts import ILogger
from src.infrastructure.database.repositories.order_repository import OrderRepository
from src.infrastructure.database.repositories.payment_repository import PaymentRepository
# from src.infrastructure.database.repositories import UserRepository, SessionRepository
from src.infrastructure.database.repositories.user_repository import UserRepository
@@ -14,14 +14,14 @@ class UnitOfWork(IUnitOfWork):
self._session: AsyncSession = None
self._order_repository: IOrderRepository | None = None
self._payment_repository: IPaymentRepository | None = None
# self._user_repository: IUserRepository = None
# self._session_repository: ISessionRepository = None
self._user_repository: IUserRepository | None = None
self._logger: ILogger = logger
async def __aenter__(self):
self._session = self.session_factory()
self._order_repository = None
self._payment_repository = None
self._user_repository = None
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
@@ -48,14 +48,9 @@ class UnitOfWork(IUnitOfWork):
self._payment_repository = PaymentRepository(session=self._session, logger=self._logger)
return self._payment_repository
# @property
# def user_repository(self) -> IUserRepository:
# if self._user_repository is None:
# self._user_repository = UserRepository(session=self._session, logger=self._logger)
# return self._user_repository
#
# @property
# def session_repository(self) -> ISessionRepository:
# if self._session_repository is None:
# self._session_repository = SessionRepository(session=self._session, logger=self._logger)
# return self._session_repository
@property
def user_repository(self) -> IUserRepository:
if self._user_repository is None:
self._user_repository = UserRepository(session=self._session, logger=self._logger)
return self._user_repository

View File

@@ -1,9 +1,10 @@
from __future__ import annotations
from fastapi import Depends
from src.application.abstractions import IUnitOfWork
from src.application.commands import CreateOrderCommand,CreatePaymentCommand
from src.application.contracts import ICache,ILogger,IQueueMessanger
from src.application.commands import CreateOrderCommand,CreatePaymentCommand,CreatePaymentCloudkassirCommand
from src.application.contracts import ICache,ILogger,IQueueMessanger,IReceipt
from src.application.contracts.i_itpay_service import IItPayService
from src.infrastructure.cloud_kassir import ClaudeKassirClient
from src.infrastructure.config import settings
from src.infrastructure.itpay.client import ItPayClient
from src.presentation.dependencies.cache import get_remote_cache
@@ -38,4 +39,22 @@ def get_create_payment_command(
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
queue_messanger: IQueueMessanger = Depends(get_rabbit),
) -> CreatePaymentCommand:
return CreatePaymentCommand(unit_of_work=unit_of_work,logger=logger,queue_messanger=queue_messanger)
return CreatePaymentCommand(unit_of_work=unit_of_work,logger=logger,queue_messanger=queue_messanger)
def get_cloud_kassir_receipt() -> IReceipt:
return ClaudeKassirClient(
public_id=settings.CLOUD_KASSIR_PUBLIC_ID,
api_secret=settings.CLOUD_KASSIR_API_SECRET,
inn=settings.CLOUD_KASSIR_INN,
api_base_url=settings.CLOUD_KASSIR_API_BASE_URL,
success_url=settings.CLOUD_KASSIR_SUCCESS_URL,
fail_url=settings.CLOUD_KASSIR_FAIL_URL,
)
def get_create_payment_cloudkassir_command(
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
receipt: IReceipt = Depends(get_cloud_kassir_receipt),
) -> CreatePaymentCloudkassirCommand:
return CreatePaymentCloudkassirCommand(unit_of_work=unit_of_work,receipt=receipt)

View File

@@ -3,12 +3,12 @@ import orjson
from fastapi import APIRouter, Depends, Request
from fastapi.responses import ORJSONResponse
from src.application.commands import CreateOrderCommand
from src.application.commands import CreatePaymentCommand
from src.application.commands import CreatePaymentCloudkassirCommand
from src.application.contracts import ILogger
from src.application.domain.dto import AuthContext
from src.application.domain.enums import OrderStatus
from src.presentation.decorators import require_access_token, csrf_protect
from src.presentation.dependencies.commands import get_create_order_command, get_create_payment_command
from src.presentation.dependencies.commands import get_create_order_command, get_create_payment_cloudkassir_command
from src.presentation.dependencies.logger import get_logger
from src.presentation.schemas.order import CreateOrder
from src.presentation.schemas.itpay_payment_models import ItpayPaymentData
@@ -73,8 +73,8 @@ async def create_order(
@order_router.post('/webhook/itpay')
async def itpay_webhook(
request: Request,
payment_command: CreatePaymentCommand = Depends(get_create_payment_command),
logger: ILogger = Depends(get_logger)
payment_command: CreatePaymentCloudkassirCommand = Depends(get_create_payment_cloudkassir_command),
logger: ILogger = Depends(get_logger),
) -> ORJSONResponse:
raw = await request.body()
ct = (request.headers.get('content-type') or '').lower()