diff --git a/.dockerignore b/.dockerignore index 4c61ef9..86fa38c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,4 +3,5 @@ __pycache__ .git .pytest_cache .mypy_cache -.ruff_cache \ No newline at end of file +.ruff_cache +.qwen/ \ No newline at end of file diff --git a/.env b/.env index c7b78bb..f04e85e 100644 --- a/.env +++ b/.env @@ -14,8 +14,9 @@ S3_HOST=s3-fairdrop S3_PORT=9000 MINIO_ROOT_USER=s3_fairdrop_user MINIO_ROOT_PASSWORD=s3_fairdrop_password -MINIO_BUCKET_NAME=s3_fairdrop-media +MINIO_BUCKET_NAME=fairdrop-media MINIO_URL=http://${S3_HOST}:${S3_PORT} +S3_PUBLIC_URL=http://localhost:9000 PRESIGNED_URL_EXPIRE_SECONDS=3600 MIN_FILE_SIZE_BYTES=1 MAX_FILE_SIZE_BYTES=5242880 diff --git a/.gitignore b/.gitignore index d2cf1fd..f767b47 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ wheels/ .vscode/ # Local env -.coverage \ No newline at end of file +.coverage +.qwen/ \ No newline at end of file diff --git a/app/core/auth_schemes.py b/app/core/auth_schemes.py index 56fce34..1a198e2 100644 --- a/app/core/auth_schemes.py +++ b/app/core/auth_schemes.py @@ -1,4 +1,7 @@ from fastapi.security import APIKeyHeader, OAuth2PasswordBearer oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/api/v1/auth/token') +oauth2_scheme_optional = OAuth2PasswordBearer( + tokenUrl='/api/v1/auth/token', auto_error=False +) header_scheme = APIKeyHeader(name='X-API-Key', auto_error=False) diff --git a/app/core/config.py b/app/core/config.py index 6190bfa..0c00704 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -20,6 +20,7 @@ class Settings(BaseSettings): minio_root_password: str = Field(alias='MINIO_ROOT_PASSWORD') minio_bucket_name: str = Field(alias='MINIO_BUCKET_NAME') minio_url: str = Field(alias='MINIO_URL') + s3_public_url: str = Field(alias='S3_PUBLIC_URL') pool_size: int = Field(alias='POOL_SIZE') max_overflow: int = Field(alias='MAX_OVERFLOW') jwt_algorithm: str = Field(default='HS256', alias='JWT_ALGORITHM') diff --git a/app/core/s3.py b/app/core/s3.py index 59601c0..30518ef 100644 --- a/app/core/s3.py +++ b/app/core/s3.py @@ -4,6 +4,7 @@ import aioboto3 # type: ignore import structlog +from botocore.config import Config from botocore.exceptions import ClientError from app.core.config import settings @@ -11,10 +12,10 @@ logger = structlog.get_logger(__name__) session = aioboto3.Session() +s3_config = Config(s3={'addressing_style': 'path'}) -@asynccontextmanager -async def get_s3_client() -> AsyncIterator[Any]: +async def get_s3_client_gen() -> AsyncIterator[Any]: async with session.client( 's3', endpoint_url=settings.minio_url, @@ -22,10 +23,14 @@ async def get_s3_client() -> AsyncIterator[Any]: aws_access_key_id=settings.minio_root_user, aws_secret_access_key=settings.minio_root_password, verify=False, + config=s3_config, ) as client: yield client +get_s3_client = asynccontextmanager(get_s3_client_gen) + + async def init_s3_bucket() -> None: async with session.client( 's3', @@ -34,13 +39,14 @@ async def init_s3_bucket() -> None: aws_access_key_id=settings.minio_root_user, aws_secret_access_key=settings.minio_root_password, verify=False, + config=s3_config, ) as client: try: await client.head_bucket(Bucket=settings.minio_bucket_name) logger.info(f'Bucket {settings.minio_bucket_name} already exists') except ClientError as e: if e.response['Error']['Code'] == '404': - await client.make_bucket(Bucket=settings.minio_bucket_name) + await client.create_bucket(Bucket=settings.minio_bucket_name) logger.info(f'Bucket {settings.minio_bucket_name} created') else: logger.error(e) diff --git a/app/services/inventory/routes.py b/app/services/inventory/routes.py index a68f906..227913d 100644 --- a/app/services/inventory/routes.py +++ b/app/services/inventory/routes.py @@ -1,10 +1,11 @@ -from typing import Annotated +from typing import Annotated, Any from uuid import UUID from fastapi import APIRouter, Depends, Header, Query, Request, status from app.core.config import settings from app.core.database import SessionDep +from app.core.s3 import get_s3_client_gen from app.core.security import RoleChecker, UserRole from app.services.inventory.models import ProductStatus from app.services.inventory.schemas import ( @@ -15,6 +16,7 @@ ReservationResponse, ) from app.services.inventory.service import InventoryAdminService, InventoryService +from app.services.media.service import generate_presigned_get_url from app.services.user.models import User from app.shared.decorators import idempotent from app.shared.deps import get_current_user @@ -50,10 +52,24 @@ ) +async def _enrich_product_images(product: Any, s3_client: Any) -> ProductRead: + """Helper to generate presigned URLs for product images.""" + read_obj = ProductRead.model_validate(product) + image_urls = [] + if hasattr(product, 'images'): + for img in product.images: + if img.status == 'active': + url = await generate_presigned_get_url(s3_client, img.file_path) + image_urls.append(url) + read_obj.image_urls = image_urls + return read_obj + + @router_v1.get('/', response_model=list[ProductRead]) async def get_active_products( session: SessionDep, service: Annotated[InventoryService, Depends(get_inventory_service)], + s3_client: Any = Depends(get_s3_client_gen), skip: int = 0, limit: int = 50, ) -> list[ProductRead]: @@ -63,7 +79,7 @@ async def get_active_products( limit=limit, session=session, ) - return [ProductRead.model_validate(p) for p in products] + return [await _enrich_product_images(p, s3_client) for p in products] @router_v1.post('/', response_model=ProductRead, status_code=status.HTTP_201_CREATED) @@ -134,12 +150,13 @@ async def get_product( product_id: UUID, session: SessionDep, service: Annotated[InventoryService, Depends(get_inventory_service)], + s3_client: Any = Depends(get_s3_client_gen), ) -> ProductRead: product = await service.get_product( session=session, product_id=product_id, ) - return ProductRead.model_validate(product) + return await _enrich_product_images(product, s3_client) @router_v1.post('/reserve', response_model=ReservationResponse) diff --git a/app/services/inventory/schemas.py b/app/services/inventory/schemas.py index 5096f3f..78868d7 100644 --- a/app/services/inventory/schemas.py +++ b/app/services/inventory/schemas.py @@ -37,6 +37,7 @@ class ProductRead(BaseModel): status: ProductStatus created_at: datetime updated_at: datetime + image_urls: list[str] = [] class ReservationCreate(BaseModel): diff --git a/app/services/inventory/service.py b/app/services/inventory/service.py index b40ba50..f7e2293 100644 --- a/app/services/inventory/service.py +++ b/app/services/inventory/service.py @@ -5,6 +5,7 @@ from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload from app.core.audit_log.service import audit_log_service from app.core.config import settings @@ -53,8 +54,10 @@ async def _get_product( query = select(Product).where(Product.id == product_id) if for_update: query = query.with_for_update() + else: + query = query.options(joinedload(Product.images)) result = await session.execute(query) - product = result.scalar_one_or_none() + product = result.unique().scalar_one_or_none() if not product: raise NotFoundError if current_user: @@ -142,9 +145,17 @@ async def update_product( product = await InventoryService._get_product( session, product_id, for_update=True, current_user=current_user ) + if product.status in ( + ProductStatus.PENDING_MODERATION, + ProductStatus.MODERATION_IN_PROGRESS, + ): + raise ConflictError('Cannot edit product while it is under moderation') old_snapshot = ProductRead.model_validate(product) for field, value in product_data.model_dump(exclude_unset=True).items(): setattr(product, field, value) + if product.status == ProductStatus.ACTIVE: + product.status = ProductStatus.PENDING_MODERATION + product.moderator_id = None await InventoryService._log_product_change( session=session, user=current_user, @@ -187,11 +198,11 @@ async def get_products( skip: int = 0, limit: int = 50, ) -> list[Product]: - query = select(Product) + query = select(Product).options(joinedload(Product.images)) if status: query = query.where(Product.status == status) result = await session.execute(query.offset(skip).limit(limit)) - return list(result.scalars().all()) + return list(result.scalars().unique().all()) @staticmethod async def reserve_items( diff --git a/app/services/media/routes.py b/app/services/media/routes.py index e768455..f72f858 100644 --- a/app/services/media/routes.py +++ b/app/services/media/routes.py @@ -8,7 +8,7 @@ from app.core.audit_log.service import audit_log_service from app.core.database import get_session -from app.core.s3 import get_s3_client +from app.core.s3 import get_s3_client_gen from app.services.media.schemas import ( ImageUploadRequest, ImageUploadResponse, @@ -21,7 +21,7 @@ handle_minio_webhook, ) from app.services.user.models import User, UserRole -from app.shared.deps import get_current_user +from app.shared.deps import get_current_user, get_current_user_flexible router_v1 = APIRouter(prefix='/media', tags=['Media']) @@ -31,7 +31,7 @@ async def create_upload_url( product_id: UUID, req: ImageUploadRequest, session: AsyncSession = Depends(get_session), - s3_client: Any = Depends(get_s3_client), + s3_client: Any = Depends(get_s3_client_gen), current_user: User = Depends(get_current_user), ) -> ImageUploadResponse: return await generate_upload_url(session, s3_client, product_id, req) @@ -53,8 +53,8 @@ async def view_private_file( target_id: UUID, doc_key: str | None = None, session: AsyncSession = Depends(get_session), - s3_client: Any = Depends(get_s3_client), - current_user: User = Depends(get_current_user), + s3_client: Any = Depends(get_s3_client_gen), + current_user: User = Depends(get_current_user_flexible), ) -> RedirectResponse: if current_user.role not in (UserRole.ADMIN, UserRole.MODERATOR): raise HTTPException( diff --git a/app/services/media/service.py b/app/services/media/service.py index 9a9c3f5..dee9b55 100644 --- a/app/services/media/service.py +++ b/app/services/media/service.py @@ -24,8 +24,37 @@ async def generate_presigned_get_url( key: str, expires_in: int = 3600, ) -> str: - """Generates a presigned GET URL for reading private files.""" - return cast( + """ + Generates a presigned GET URL for reading private files with host substitution. + """ + logger.debug( + 'generating s3 url', + input_key=key, + current_bucket=settings.minio_bucket_name, + ) + original_key = key + if '://' in key: + parts = key.split('/', 3) + if len(parts) >= 4: + key = parts[3] + bucket_candidates = [ + settings.minio_bucket_name, + 's3_fairdrop-media', + 's3-fairdrop-media', + ] + while True: + key = key.lstrip('/') + stripped = False + for b in bucket_candidates: + if key.startswith(f'{b}/'): + key = key[len(b) + 1 :] + stripped = True + if not stripped: + break + key = key.lstrip('/') + if key != original_key: + logger.debug('sanitized s3 key', original=original_key, sanitized=key) + url = cast( str, await s3_client.generate_presigned_url( 'get_object', @@ -33,6 +62,9 @@ async def generate_presigned_get_url( ExpiresIn=expires_in, ), ) + if settings.minio_url != settings.s3_public_url: + url = url.replace(settings.minio_url, settings.s3_public_url) + return url async def get_secure_file_path( diff --git a/app/services/user/routes.py b/app/services/user/routes.py index cd71b28..90b6b12 100644 --- a/app/services/user/routes.py +++ b/app/services/user/routes.py @@ -2,7 +2,7 @@ from typing import Annotated from uuid import UUID -from fastapi import APIRouter, Depends, Request, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy import select @@ -134,3 +134,31 @@ async def create_upgrade_request( docs_url=schema.docs_url, ) return VerificationRequestRead.model_validate(verification_request) + + +@router_v1.get( + '/users/me/upgrade-requests', response_model=list[VerificationRequestRead] +) +async def get_upgrade_requests( + current_user: Annotated[User, Depends(get_current_user)], + session: SessionDep, +) -> list[VerificationRequestRead]: + requests = await UserService.get_verification_requests(session, current_user.id) + return [VerificationRequestRead.model_validate(req) for req in requests] + + +@router_v1.get( + '/users/me/upgrade-requests/latest', response_model=VerificationRequestRead +) +async def get_latest_upgrade_request( + current_user: Annotated[User, Depends(get_current_user)], + session: SessionDep, +) -> VerificationRequestRead: + verification_request = await UserService.get_latest_verification_request( + session, current_user.id + ) + if not verification_request: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail='No upgrade requests found' + ) + return VerificationRequestRead.model_validate(verification_request) diff --git a/app/services/user/schemas.py b/app/services/user/schemas.py index 9d1ab7f..ecb2e5b 100644 --- a/app/services/user/schemas.py +++ b/app/services/user/schemas.py @@ -49,12 +49,15 @@ class APIKeyWithSecret(APIKeyRead): class VerificationRequestCreate(BaseModel): - target_role: Literal[UserRole.USER_B2B, UserRole.SELLER_B2B] + target_role: Literal[UserRole.USER_B2B, UserRole.SELLER_B2B, UserRole.SELLER] docs_url: dict[str, str] | None = None class VerificationRequestRead(BaseModel): model_config = ConfigDict(from_attributes=True) id: UUID + target_role: UserRole status: VerificationStatus + admin_feedback: str | None = None created_at: datetime + updated_at: datetime diff --git a/app/services/user/service.py b/app/services/user/service.py index 8571186..9d9b8a5 100644 --- a/app/services/user/service.py +++ b/app/services/user/service.py @@ -174,3 +174,28 @@ async def create_verification_request( await session.commit() await session.refresh(verification_request) return verification_request + + @staticmethod + async def get_verification_requests( + session: AsyncSession, + user_id: UUID, + ) -> list[VerificationRequest]: + result = await session.execute( + select(VerificationRequest) + .where(VerificationRequest.user_id == user_id) + .order_by(VerificationRequest.created_at.desc()) + ) + return list(result.scalars().all()) + + @staticmethod + async def get_latest_verification_request( + session: AsyncSession, + user_id: UUID, + ) -> VerificationRequest | None: + result = await session.execute( + select(VerificationRequest) + .where(VerificationRequest.user_id == user_id) + .order_by(VerificationRequest.created_at.desc()) + .limit(1) + ) + return result.scalar_one_or_none() diff --git a/app/shared/deps.py b/app/shared/deps.py index 4aafa19..e0f57bb 100644 --- a/app/shared/deps.py +++ b/app/shared/deps.py @@ -1,10 +1,10 @@ from typing import Annotated -from fastapi import Depends +from fastapi import Depends, Request from jose import JWTError, jwt from sqlalchemy import select -from app.core.auth_schemes import header_scheme, oauth2_scheme +from app.core.auth_schemes import header_scheme, oauth2_scheme, oauth2_scheme_optional from app.core.config import settings from app.core.database import SessionDep from app.core.exceptions import CredentialsError, PermissionDeniedError @@ -32,6 +32,26 @@ async def get_current_user( raise CredentialsError() +async def get_current_user_flexible( + request: Request, + session: SessionDep, + token: str | None = Depends(oauth2_scheme_optional), +) -> User: + if token: + try: + return await get_current_user(token, session) + except CredentialsError: + pass + user_id = request.session.get('token') + if user_id: + result = await session.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if user: + return user + + raise CredentialsError() + + async def get_api_key_user( api_key: Annotated[str | None, Depends(header_scheme)], session: SessionDep, diff --git a/tests/test_inventory_routes_coverage.py b/tests/test_inventory_routes_coverage.py new file mode 100644 index 0000000..7371e70 --- /dev/null +++ b/tests/test_inventory_routes_coverage.py @@ -0,0 +1,78 @@ +from collections.abc import Callable +from http import HTTPStatus + +import pytest +from httpx import AsyncClient +from sqlalchemy import update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.inventory.models import Product, ProductStatus + + +@pytest.mark.asyncio +async def test_inventory_routes_full_coverage( + async_client: AsyncClient, + db_session: AsyncSession, + admin_headers: dict, + seller_headers: dict, + create_test_product: Callable, +) -> None: + resp = await async_client.post( + '/api/v1/inventory/', + json={ + 'name': 'Route Test Product', + 'description': 'Desc', + 'price': '100.00', + 'qty_available': 10, + }, + headers=seller_headers, + ) + assert resp.status_code == HTTPStatus.CREATED + product_id = resp.json()['id'] + resp = await async_client.get('/api/v1/inventory/') + assert resp.status_code == HTTPStatus.OK + await db_session.execute( + update(Product) + .where(Product.id == product_id) + .values(status=ProductStatus.PENDING_MODERATION) + ) + await db_session.commit() + resp = await async_client.post( + f'/api/v1/inventory/{product_id}/claim', headers=admin_headers + ) + assert resp.status_code == HTTPStatus.OK + resp = await async_client.post( + f'/api/v1/inventory/{product_id}/reject', + params={'reason': 'Testing rejection'}, + headers=admin_headers, + ) + assert resp.status_code == HTTPStatus.OK + assert resp.json()['status'] == 'REJECTED' + await db_session.execute( + update(Product) + .where(Product.id == product_id) + .values(status=ProductStatus.MODERATION_IN_PROGRESS) + ) + await db_session.commit() + resp = await async_client.post( + f'/api/v1/inventory/{product_id}/approve', headers=admin_headers + ) + assert resp.status_code == HTTPStatus.OK + assert resp.json()['status'] == 'ACTIVE' + resp = await async_client.get(f'/api/v1/inventory/{product_id}') + assert resp.status_code == HTTPStatus.OK + assert 'image_urls' in resp.json() + await db_session.execute( + update(Product) + .where(Product.id == product_id) + .values(status=ProductStatus.DRAFT) + ) + await db_session.commit() + resp = await async_client.patch( + f'/api/v1/inventory/{product_id}/activate', headers=admin_headers + ) + assert resp.status_code == HTTPStatus.OK + resp = await async_client.delete( + f'/api/v1/inventory/{product_id}', headers=admin_headers + ) + assert resp.status_code == HTTPStatus.NO_CONTENT diff --git a/tests/test_inventory_service_coverage.py b/tests/test_inventory_service_coverage.py index d272e39..612f47e 100644 --- a/tests/test_inventory_service_coverage.py +++ b/tests/test_inventory_service_coverage.py @@ -15,7 +15,7 @@ async def sample_seller(db_session: Any) -> Any: u = User( id=uuid4(), - email=f'seller_{uuid4().hex[:4]}@mail.com', + email=f'seller_{uuid4().hex}@mail.com', password_hash='h', role=UserRole.SELLER, ) @@ -29,7 +29,7 @@ async def sample_seller(db_session: Any) -> Any: async def sample_moderator(db_session: Any) -> Any: u = User( id=uuid4(), - email=f'mod_{uuid4().hex[:4]}@mail.com', + email=f'mod_{uuid4().hex}@mail.com', password_hash='h', role=UserRole.MODERATOR, ) @@ -75,7 +75,6 @@ async def draft_product(db_session: Any, sample_seller: Any) -> Any: async def test_submit_for_moderation_conflict( db_session: Any, sample_product: Any, sample_seller: Any ) -> None: - # product is ACTIVE, not DRAFT/REJECTED with pytest.raises(ConflictError): await InventoryService.submit_for_moderation( db_session, sample_product.id, sample_seller @@ -97,7 +96,6 @@ async def test_reserve_items_product_not_found( async def test_claim_for_moderation_conflict( db_session: Any, sample_product: Any, sample_moderator: Any ) -> None: - # product is ACTIVE, not PENDING_MODERATION with pytest.raises(ConflictError): await InventoryAdminService.claim_for_moderation( db_session, sample_product.id, sample_moderator @@ -108,7 +106,6 @@ async def test_claim_for_moderation_conflict( async def test_approve_product_conflict( db_session: Any, draft_product: Any, sample_moderator: Any ) -> None: - # product is DRAFT, not MODERATION_IN_PROGRESS with pytest.raises(ConflictError): await InventoryAdminService.approve_product( db_session, draft_product.id, sample_moderator @@ -119,7 +116,6 @@ async def test_approve_product_conflict( async def test_reject_product_conflict( db_session: Any, draft_product: Any, sample_moderator: Any ) -> None: - # product is DRAFT, not MODERATION_IN_PROGRESS with pytest.raises(ConflictError): await InventoryAdminService.reject_product( db_session, draft_product.id, sample_moderator, 'bad product' diff --git a/tests/test_product_logic_hardening.py b/tests/test_product_logic_hardening.py new file mode 100644 index 0000000..501646e --- /dev/null +++ b/tests/test_product_logic_hardening.py @@ -0,0 +1,55 @@ +from collections.abc import Callable +from http import HTTPStatus + +import pytest +from httpx import AsyncClient +from sqlalchemy import update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import create_access_token +from app.services.inventory.models import Product, ProductStatus +from app.services.user.models import UserRole + + +@pytest.mark.asyncio +async def test_update_product_status_logic( + async_client: AsyncClient, + db_session: AsyncSession, + create_test_user: Callable, + create_test_product: Callable, +) -> None: + user = await create_test_user(role=UserRole.SELLER, email_prefix='harden') + user_id = user.id + token = create_access_token({'sub': user.email, 'role': user.role}) + headers = {'Authorization': f'Bearer {token}'} + product = await create_test_product(owner_id=user_id) + product_id = product.id + await db_session.execute( + update(Product) + .where(Product.id == product_id) + .values(status=ProductStatus.PENDING_MODERATION) + ) + await db_session.commit() + resp = await async_client.patch( + f'/api/v1/inventory/{product_id}', + json={'name': 'New Name'}, + headers=headers, + ) + assert resp.status_code == HTTPStatus.CONFLICT + assert 'under moderation' in resp.json()['detail'] + await db_session.execute( + update(Product) + .where(Product.id == product_id) + .values(status=ProductStatus.ACTIVE) + ) + await db_session.commit() + resp = await async_client.patch( + f'/api/v1/inventory/{product_id}', + json={'price': '99.99'}, + headers=headers, + ) + assert resp.status_code == HTTPStatus.OK + data = resp.json() + assert data['status'] == 'PENDING_MODERATION' + assert data['price'] == '99.99' + assert 'image_urls' in data diff --git a/tests/test_services_coverage_industrial.py b/tests/test_services_coverage_industrial.py index 798f7bc..ae08529 100644 --- a/tests/test_services_coverage_industrial.py +++ b/tests/test_services_coverage_industrial.py @@ -64,10 +64,12 @@ async def commit(self) -> None: async def execute(self, stmt: Any) -> Any: res_obj = self.responses.popleft() if self.responses else None res = MagicMock() + res.unique.return_value = res + res.scalars.return_value = res res.scalar_one_or_none.return_value = res_obj res.scalar_one.return_value = res_obj res.scalar.return_value = res_obj - res.scalars.return_value.all.return_value = ( + res.all.return_value = ( res_obj if isinstance(res_obj, list) else [res_obj] if res_obj else [] ) res.with_for_update.return_value = res diff --git a/tests/test_user_service_coverage.py b/tests/test_user_service_coverage.py index 5cfadaa..68312cd 100644 --- a/tests/test_user_service_coverage.py +++ b/tests/test_user_service_coverage.py @@ -28,7 +28,7 @@ async def sample_user(db_session: Any) -> Any: user = User( id=uuid4(), - email=f'usr_{uuid4().hex[:4]}@mail.com', + email=f'usr_{uuid4().hex}@mail.com', password_hash=await get_password_hash('password123'), role=UserRole.USER, ) @@ -40,7 +40,7 @@ async def sample_user(db_session: Any) -> Any: @pytest.mark.asyncio async def test_create_user_success(db_session: Any) -> None: - email = f'new_{uuid4().hex[:4]}@mail.com' + email = f'new_{uuid4().hex}@mail.com' user_create = UserCreate(email=email, password='secure!') user = await UserService.create_user(db_session, user_create) assert user.email == email @@ -84,12 +84,8 @@ async def test_create_and_refresh_access_token( ) -> None: token = await UserService.create_refresh_token(db_session, sample_user.id) assert token is not None - - # Refresh token user = await UserService.refresh_access_token(db_session, token) assert user.id == sample_user.id - - # Verify token is deleted res = await db_session.execute( select(RefreshToken).where(RefreshToken.token == token) ) diff --git a/tests/test_verification_status.py b/tests/test_verification_status.py new file mode 100644 index 0000000..d42a1f0 --- /dev/null +++ b/tests/test_verification_status.py @@ -0,0 +1,43 @@ +import pytest +from httpx import AsyncClient +from sqlalchemy import update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.user.models import VerificationRequest, VerificationStatus + + +@pytest.mark.asyncio +async def test_get_upgrade_requests( + async_client: AsyncClient, + db_session: AsyncSession, + buyer_headers: dict, +) -> None: + resp = await async_client.post( + '/api/v1/users/me/upgrade-requests', + json={'target_role': 'SELLER', 'docs_url': {'id_card': 'url1'}}, + headers=buyer_headers, + ) + assert resp.status_code == 201 + resp = await async_client.get( + '/api/v1/users/me/upgrade-requests/latest', + headers=buyer_headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert data['status'] == 'PENDING' + assert data['target_role'] == 'SELLER' + assert data['admin_feedback'] is None + await db_session.execute( + update(VerificationRequest).values( + status=VerificationStatus.REJECTED, admin_feedback='Bad photos' + ) + ) + await db_session.commit() + resp = await async_client.get( + '/api/v1/users/me/upgrade-requests/latest', + headers=buyer_headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert data['status'] == 'REJECTED' + assert data['admin_feedback'] == 'Bad photos'