diff --git a/app/application/use_cases/create_post.py b/app/application/use_cases/create_post.py index a815785..ff2fe37 100644 --- a/app/application/use_cases/create_post.py +++ b/app/application/use_cases/create_post.py @@ -27,9 +27,7 @@ class CreatePostUseCase: # Check if slug already exists if await self._post_repo.slug_exists(slug.value): - raise AlreadyExistsException( - f"Post with slug '{slug.value}' already exists" - ) + raise AlreadyExistsException(f"Post with slug '{slug.value}' already exists") # Create domain entity post = Post.create( diff --git a/app/application/use_cases/list_posts.py b/app/application/use_cases/list_posts.py index 634b267..f14199b 100644 --- a/app/application/use_cases/list_posts.py +++ b/app/application/use_cases/list_posts.py @@ -38,9 +38,7 @@ class ListPostsUseCase: offset: int | None = None, ) -> list[PostResponseDTO]: """Get posts by author.""" - posts = await self._post_repo.get_by_author( - author_id, limit=limit, offset=offset - ) + posts = await self._post_repo.get_by_author(author_id, limit=limit, offset=offset) return [self._map_to_dto(post) for post in posts] async def by_tag( diff --git a/app/domain/entities/base.py b/app/domain/entities/base.py index e65ba38..8f20c51 100644 --- a/app/domain/entities/base.py +++ b/app/domain/entities/base.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any from uuid import UUID, uuid4 @@ -12,8 +12,8 @@ class BaseEntity(ABC): """Base class for all domain entities.""" id: UUID = field(default_factory=uuid4) - created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) - updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + updated_at: datetime = field(default_factory=lambda: datetime.now(UTC)) def __eq__(self, other: object) -> bool: if not isinstance(other, BaseEntity): @@ -25,7 +25,7 @@ class BaseEntity(ABC): def touch(self) -> None: """Update the updated_at timestamp.""" - self.updated_at = datetime.now(timezone.utc) + self.updated_at = datetime.now(UTC) @abstractmethod def to_dict(self) -> dict[str, Any]: diff --git a/app/domain/roles.py b/app/domain/roles.py index d84e2eb..08e3ed4 100644 --- a/app/domain/roles.py +++ b/app/domain/roles.py @@ -1,8 +1,9 @@ """Role-based access control definitions.""" +from collections.abc import Callable from enum import Enum from functools import wraps -from typing import Any, Callable +from typing import Any from app.domain.exceptions import ForbiddenException diff --git a/app/domain/value_objects/slug.py b/app/domain/value_objects/slug.py index fe7df71..13e27f3 100644 --- a/app/domain/value_objects/slug.py +++ b/app/domain/value_objects/slug.py @@ -19,9 +19,7 @@ class Slug(ValueObject[str]): if len(self.value) > self.MAX_LENGTH: raise ValueError(f"Slug must be at most {self.MAX_LENGTH} characters") if not re.match(self.SLUG_PATTERN, self.value): - raise ValueError( - "Slug must contain only lowercase letters, numbers, and hyphens" - ) + raise ValueError("Slug must contain only lowercase letters, numbers, and hyphens") @classmethod def from_title(cls, title: str) -> "Slug": diff --git a/app/infrastructure/database/connection.py b/app/infrastructure/database/connection.py index 74b46a6..c9c2a7b 100644 --- a/app/infrastructure/database/connection.py +++ b/app/infrastructure/database/connection.py @@ -1,7 +1,7 @@ """Database connection and session management.""" +from collections.abc import AsyncGenerator from contextlib import asynccontextmanager -from typing import AsyncGenerator from sqlalchemy.ext.asyncio import ( AsyncEngine, @@ -38,7 +38,7 @@ AsyncSessionLocal = async_sessionmaker( ) -async def get_session() -> AsyncGenerator[AsyncSession, None]: +async def get_session() -> AsyncGenerator[AsyncSession]: """Get database session.""" async with AsyncSessionLocal() as session: try: @@ -48,7 +48,7 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]: @asynccontextmanager -async def get_session_context() -> AsyncGenerator[AsyncSession, None]: +async def get_session_context() -> AsyncGenerator[AsyncSession]: """Get database session as context manager.""" async with AsyncSessionLocal() as session: try: diff --git a/app/infrastructure/database/models.py b/app/infrastructure/database/models.py index a2f1eaa..fbbcb0d 100644 --- a/app/infrastructure/database/models.py +++ b/app/infrastructure/database/models.py @@ -1,6 +1,6 @@ """SQLAlchemy ORM models.""" -from datetime import datetime, timezone +from datetime import UTC, datetime from uuid import uuid4 from sqlalchemy import JSON, Boolean, DateTime, String, Text @@ -14,27 +14,21 @@ class PostORM(Base): # type: ignore[valid-type,misc] __tablename__ = "posts" - id: Mapped[str] = mapped_column( - String(36), primary_key=True, default=lambda: str(uuid4()) - ) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) title: Mapped[str] = mapped_column(String(200), nullable=False) content: Mapped[str] = mapped_column(Text, nullable=False) - slug: Mapped[str] = mapped_column( - String(200), nullable=False, unique=True, index=True - ) + slug: Mapped[str] = mapped_column(String(200), nullable=False, unique=True, index=True) author_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True) - published: Mapped[bool] = mapped_column( - Boolean, default=False, nullable=False, index=True - ) + published: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True) tags: Mapped[list[str]] = mapped_column(JSON, default=list) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), - default=lambda: datetime.now(timezone.utc), + default=lambda: datetime.now(UTC), nullable=False, ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), - default=lambda: datetime.now(timezone.utc), - onupdate=lambda: datetime.now(timezone.utc), + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), nullable=False, ) diff --git a/app/infrastructure/di/providers.py b/app/infrastructure/di/providers.py index d017653..bf5739c 100644 --- a/app/infrastructure/di/providers.py +++ b/app/infrastructure/di/providers.py @@ -1,6 +1,6 @@ """Dishka providers for dependency injection.""" -from typing import AsyncGenerator +from collections.abc import AsyncGenerator from dishka import Provider, Scope, provide from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession @@ -30,7 +30,7 @@ class DatabaseProvider(Provider): return engine @provide(scope=Scope.REQUEST) - async def get_session(self) -> AsyncGenerator[AsyncSession, None]: + async def get_session(self) -> AsyncGenerator[AsyncSession]: """Provide database session per request.""" async with AsyncSessionLocal() as session: try: diff --git a/app/infrastructure/middleware/error_handler.py b/app/infrastructure/middleware/error_handler.py index b25ed3c..c039389 100644 --- a/app/infrastructure/middleware/error_handler.py +++ b/app/infrastructure/middleware/error_handler.py @@ -1,6 +1,6 @@ """Exception handling middleware.""" -from datetime import datetime, timezone +from datetime import UTC, datetime from fastapi import FastAPI, Request from fastapi.responses import JSONResponse @@ -33,9 +33,7 @@ def get_status_code(exc: DomainException) -> int: return 500 -async def domain_exception_handler( - request: Request, exc: DomainException -) -> JSONResponse: +async def domain_exception_handler(request: Request, exc: DomainException) -> JSONResponse: """Handle domain exceptions.""" status_code = get_status_code(exc) return JSONResponse( @@ -43,22 +41,20 @@ async def domain_exception_handler( content={ "error": exc.__class__.__name__, "message": exc.message, - "timestamp": datetime.now(timezone.utc).isoformat(), + "timestamp": datetime.now(UTC).isoformat(), "path": str(request.url.path), }, ) -async def http_exception_handler( - request: Request, exc: StarletteHTTPException -) -> JSONResponse: +async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse: """Handle HTTP exceptions.""" return JSONResponse( status_code=exc.status_code, content={ "error": "HTTPException", "message": str(exc.detail), - "timestamp": datetime.now(timezone.utc).isoformat(), + "timestamp": datetime.now(UTC).isoformat(), "path": str(request.url.path), }, ) @@ -71,7 +67,7 @@ async def generic_exception_handler(request: Request, exc: Exception) -> JSONRes content={ "error": "InternalServerError", "message": "An unexpected error occurred", - "timestamp": datetime.now(timezone.utc).isoformat(), + "timestamp": datetime.now(UTC).isoformat(), "path": str(request.url.path), }, ) diff --git a/app/infrastructure/repositories/post.py b/app/infrastructure/repositories/post.py index 0ececfb..d120994 100644 --- a/app/infrastructure/repositories/post.py +++ b/app/infrastructure/repositories/post.py @@ -47,9 +47,7 @@ class SQLAlchemyPostRepository(PostRepository): async def get_by_id(self, entity_id: UUID) -> Post | None: """Get post by ID.""" - result = await self._session.execute( - select(PostORM).where(PostORM.id == str(entity_id)) - ) + result = await self._session.execute(select(PostORM).where(PostORM.id == str(entity_id))) orm = result.scalar_one_or_none() return self._to_domain(orm) if orm else None @@ -67,9 +65,7 @@ class SQLAlchemyPostRepository(PostRepository): async def update(self, entity: Post) -> None: """Update existing post.""" - result = await self._session.execute( - select(PostORM).where(PostORM.id == str(entity.id)) - ) + result = await self._session.execute(select(PostORM).where(PostORM.id == str(entity.id))) orm = result.scalar_one() orm.title = entity.title.value @@ -83,25 +79,19 @@ class SQLAlchemyPostRepository(PostRepository): async def delete(self, entity_id: UUID) -> None: """Delete post by ID.""" - result = await self._session.execute( - select(PostORM).where(PostORM.id == str(entity_id)) - ) + result = await self._session.execute(select(PostORM).where(PostORM.id == str(entity_id))) orm = result.scalar_one_or_none() if orm: await self._session.delete(orm) async def exists(self, entity_id: UUID) -> bool: """Check if post exists.""" - result = await self._session.execute( - select(PostORM).where(PostORM.id == str(entity_id)) - ) + result = await self._session.execute(select(PostORM).where(PostORM.id == str(entity_id))) return result.scalar_one_or_none() is not None async def get_by_slug(self, slug: str) -> Post | None: """Get post by slug.""" - result = await self._session.execute( - select(PostORM).where(PostORM.slug == slug) - ) + result = await self._session.execute(select(PostORM).where(PostORM.slug == slug)) orm = result.scalar_one_or_none() return self._to_domain(orm) if orm else None @@ -154,9 +144,7 @@ class SQLAlchemyPostRepository(PostRepository): async def slug_exists(self, slug: str) -> bool: """Check if slug exists.""" - result = await self._session.execute( - select(PostORM).where(PostORM.slug == slug) - ) + result = await self._session.execute(select(PostORM).where(PostORM.slug == slug)) return result.scalar_one_or_none() is not None async def search( diff --git a/app/main.py b/app/main.py index b410551..a18a178 100644 --- a/app/main.py +++ b/app/main.py @@ -1,7 +1,7 @@ """Application entry point with DDD architecture.""" +from collections.abc import AsyncGenerator from contextlib import asynccontextmanager -from typing import AsyncGenerator import uvicorn from dishka import make_async_container @@ -21,7 +21,7 @@ from app.presentation import router @asynccontextmanager -async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: +async def lifespan(app: FastAPI) -> AsyncGenerator[None]: """Application lifespan manager.""" # Startup await init_db() diff --git a/tests/api/conftest.py b/tests/api/conftest.py index cfa6b57..ce84b26 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -1,6 +1,6 @@ """API test fixtures.""" -from typing import AsyncGenerator +from collections.abc import AsyncGenerator from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -25,7 +25,7 @@ def mock_keycloak_client() -> MagicMock: @pytest.fixture -async def client(mock_keycloak_client: MagicMock) -> AsyncGenerator[AsyncClient, None]: +async def client(mock_keycloak_client: MagicMock) -> AsyncGenerator[AsyncClient]: """Create async HTTP client for API testing.""" with patch( "app.presentation.api.deps.KeycloakAuthClient", diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index a449049..6ec1ba0 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -1,14 +1,14 @@ # E2E test fixtures # Provides: full application state, end-to-end workflows, cleanup -from typing import AsyncGenerator +from collections.abc import AsyncGenerator import pytest from fastapi import FastAPI @pytest.fixture -async def e2e_app() -> AsyncGenerator[FastAPI, None]: +async def e2e_app() -> AsyncGenerator[FastAPI]: """Create full application instance for E2E testing.""" from app.main import app_factory diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 7c9f713..75e1281 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,6 +1,6 @@ """Integration test fixtures.""" -from typing import AsyncGenerator +from collections.abc import AsyncGenerator import pytest from sqlalchemy.ext.asyncio import ( @@ -39,7 +39,7 @@ def session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: @pytest.fixture(autouse=True) -async def setup_db(engine: AsyncEngine) -> AsyncGenerator[None, None]: +async def setup_db(engine: AsyncEngine) -> AsyncGenerator[None]: """Setup database tables for each test.""" async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) @@ -51,7 +51,7 @@ async def setup_db(engine: AsyncEngine) -> AsyncGenerator[None, None]: @pytest.fixture async def db_session( session_factory: async_sessionmaker[AsyncSession], -) -> AsyncGenerator[AsyncSession, None]: +) -> AsyncGenerator[AsyncSession]: """Create database session for testing.""" async with session_factory() as session: yield session diff --git a/tests/unit/infrastructure/test_auth.py b/tests/unit/infrastructure/test_auth.py index 7cf875a..8c5c178 100644 --- a/tests/unit/infrastructure/test_auth.py +++ b/tests/unit/infrastructure/test_auth.py @@ -123,9 +123,7 @@ class TestKeycloakAuthClient: """Create Keycloak client.""" return KeycloakAuthClient(settings) - def test_client_initialization( - self, client: KeycloakAuthClient, settings: Settings - ) -> None: + def test_client_initialization(self, client: KeycloakAuthClient, settings: Settings) -> None: """Test client initialization.""" assert client._settings == settings assert client._base_url == "http://localhost:8080/realms/test-realm" @@ -144,10 +142,7 @@ class TestKeycloakAuthClient: def test_get_userinfo_url(self, client: KeycloakAuthClient) -> None: """Test userinfo URL generation.""" url = client._get_userinfo_url() - assert ( - url - == "http://localhost:8080/realms/test-realm/protocol/openid-connect/userinfo" - ) + assert url == "http://localhost:8080/realms/test-realm/protocol/openid-connect/userinfo" @pytest.mark.asyncio async def test_introspect_token_success(self, client: KeycloakAuthClient) -> None: @@ -196,18 +191,14 @@ class TestKeycloakAuthClient: assert result.is_valid is False @pytest.mark.asyncio - async def test_introspect_token_http_error( - self, client: KeycloakAuthClient - ) -> None: + async def test_introspect_token_http_error(self, client: KeycloakAuthClient) -> None: """Test introspection with HTTP error.""" import httpx mock_async_client = AsyncMock() mock_async_client.__aenter__ = AsyncMock(return_value=mock_async_client) mock_async_client.__aexit__ = AsyncMock(return_value=None) - mock_async_client.post = AsyncMock( - side_effect=httpx.HTTPError("Connection error") - ) + mock_async_client.post = AsyncMock(side_effect=httpx.HTTPError("Connection error")) with patch("httpx.AsyncClient", return_value=mock_async_client): result = await client.introspect_token("test-token") @@ -216,9 +207,7 @@ class TestKeycloakAuthClient: assert result.is_valid is False @pytest.mark.asyncio - async def test_introspect_token_uses_cache( - self, client: KeycloakAuthClient - ) -> None: + async def test_introspect_token_uses_cache(self, client: KeycloakAuthClient) -> None: """Test that token introspection uses cache.""" mock_response = Mock() mock_response.json.return_value = { @@ -283,9 +272,7 @@ class TestKeycloakAuthClient: mock_async_client = AsyncMock() mock_async_client.__aenter__ = AsyncMock(return_value=mock_async_client) mock_async_client.__aexit__ = AsyncMock(return_value=None) - mock_async_client.get = AsyncMock( - side_effect=httpx.HTTPError("Connection error") - ) + mock_async_client.get = AsyncMock(side_effect=httpx.HTTPError("Connection error")) with patch("httpx.AsyncClient", return_value=mock_async_client): result = await client.get_userinfo("test-token") @@ -293,9 +280,7 @@ class TestKeycloakAuthClient: assert result is None @pytest.mark.asyncio - async def test_introspect_token_no_realm_roles( - self, client: KeycloakAuthClient - ) -> None: + async def test_introspect_token_no_realm_roles(self, client: KeycloakAuthClient) -> None: """Test introspection without realm_access roles.""" mock_response = Mock() mock_response.json.return_value = { diff --git a/tests/unit/infrastructure/test_config.py b/tests/unit/infrastructure/test_config.py index bdd1cce..ee02b8d 100644 --- a/tests/unit/infrastructure/test_config.py +++ b/tests/unit/infrastructure/test_config.py @@ -129,10 +129,7 @@ class TestSettings: security=SecurityConfig(secret_key="test"), kc=KCConfig(client_secret="test"), ) - assert ( - s.database_url - == "postgresql+asyncpg://admin:secret@db.example.com:5433/mydb" - ) + assert s.database_url == "postgresql+asyncpg://admin:secret@db.example.com:5433/mydb" def test_database_url_override(self) -> None: """Test that explicit database URL overrides auto-building."""