From c17cb042b1990d0b14ea23c4c3c94c30f736e689 Mon Sep 17 00:00:00 2001 From: Ivan Skvortsov Date: Fri, 1 May 2026 14:02:11 +0200 Subject: [PATCH 1/8] chore(config): refactor Settings class and environment loading --- .gitignore | 1 - backend/.env.test | 7 +++ backend/app/settings.py | 85 ++++++++++++++++++++++++++++++---- backend/tests/conftest.py | 3 +- backend/tests/test_settings.py | 45 +++++++++++++----- 5 files changed, 116 insertions(+), 25 deletions(-) create mode 100644 backend/.env.test diff --git a/.gitignore b/.gitignore index e39d45c..e768b80 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ # Environment files .env -.env.test # Editor configs .idea/ diff --git a/backend/.env.test b/backend/.env.test new file mode 100644 index 0000000..ad777c2 --- /dev/null +++ b/backend/.env.test @@ -0,0 +1,7 @@ +ENV=test +DATABASE_URL=postgresql+psycopg2://evsy_test:evsy_test@localhost:5433/evsy_test +SECRET_KEY=YOUR_32_CHAR_SECRET_KEY +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= \ No newline at end of file diff --git a/backend/app/settings.py b/backend/app/settings.py index 6c02062..8ec3c35 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -1,3 +1,10 @@ +import os +from functools import lru_cache +from typing import Literal, Optional, Any + +from pydantic import Field, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + import os from functools import lru_cache from pathlib import Path @@ -7,7 +14,6 @@ from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict - def resolve_env_file() -> Optional[str]: # 1. Check if ENV is set (e.g., ENV=test) env_mode = os.getenv("ENV") @@ -49,24 +55,42 @@ def __init__(self, _env_file: Optional[str] = None, **kwargs): super().__init__(**kwargs) env: Literal["dev", "prod", "demo", "test"] = Field(default="dev", alias="ENV") - database_url: str = "sqlite:///./test.db" - frontend_url: Optional[str] = None - backend_url: str = "http://localhost:8000" - - secret_key: str = "your_secret_key_here" + database_url: str = Field(default="", alias="DATABASE_URL") + frontend_url: str = Field(default="http://localhost:3000", alias="FRONTEND_URL") + backend_url: str = Field(default="http://localhost:8000", alias="BACKEND_URL") + + secret_key: str = Field(default="your_secret_key_here", alias="SECRET_KEY") + jwt_algorithm: str = Field(default="HS256", alias="JWT_ALGORITHM") + access_token_expire_minutes: int = Field( + default=60, alias="ACCESS_TOKEN_EXPIRE_MINUTES" + ) - github_client_id: Optional[str] = None - github_client_secret: Optional[str] = None + github_client_id: Optional[str] = Field(default=None, alias="GITHUB_CLIENT_ID") + github_client_secret: Optional[str] = Field( + default=None, alias="GITHUB_CLIENT_SECRET" + ) - google_client_id: Optional[str] = None - google_client_secret: Optional[str] = None + google_client_id: Optional[str] = Field(default=None, alias="GOOGLE_CLIENT_ID") + google_client_secret: Optional[str] = Field( + default=None, alias="GOOGLE_CLIENT_SECRET" + ) model_config = SettingsConfigDict( env_file_encoding="utf-8", case_sensitive=False, extra="ignore", + populate_by_name=True, ) + @model_validator(mode="after") + def validate_infrastructure(self) -> "Settings": + """Ensure critical infrastructure settings are provided.""" + if not self.database_url: + raise ValueError( + "DATABASE_URL must be provided in the environment or a .env file" + ) + return self + @property def is_dev(self): return self.env == "dev" @@ -79,6 +103,47 @@ def is_prod(self): def is_demo(self): return self.env == "demo" + @property + def log_level(self) -> str: + if self.is_dev or self.env == "test": + return "DEBUG" + return "INFO" + + @property + def cors_origins(self) -> list[str]: + origins = [self.frontend_url] + if self.is_dev: + origins.extend(["http://localhost:5173", "http://localhost:3000"]) + return list(set(filter(None, origins))) + + @property + def masked_database_url(self) -> str: + """Returns the database URL with password masked.""" + from urllib.parse import urlparse, urlunparse + + if not self.database_url: + return "" + + parsed = urlparse(self.database_url) + + # If there's no password, return as is + if not parsed.password: + return self.database_url + + # Reconstruct the netloc (user:pass@host:port) + # We replace only the password part + host_port = parsed.hostname or "" + if parsed.port: + host_port = f"{host_port}:{parsed.port}" + + # Build the masked netloc: user:******@host:port + user_part = f"{parsed.username}:******@" if parsed.username else "******@" + new_netloc = f"{user_part}{host_port}" + + # urlunparse reassembles the 6-part tuple: + # (scheme, netloc, path, params, query, fragment) + return urlunparse(parsed._replace(netloc=new_netloc)) + @property def available_oauth_providers(self) -> list[str]: providers = [] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 7f32dec..a549042 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -17,7 +17,6 @@ @pytest.fixture(scope="session") def test_settings(): """Session-scoped fixture for test settings, using a Postgres DB.""" - # We explicitly pass the env file to ensure we use exactly what we want return Settings(_env_file=".env.test") @@ -35,7 +34,7 @@ def app(test_settings: Settings, db_engine): """Session-scoped fixture for the FastAPI application instance.""" # We use a dummy session_local here because we'll override get_db at the request level dummy_session_local = sessionmaker(bind=db_engine) - return create_app(test_settings, dummy_session_local) + return create_app(test_settings, db_engine, dummy_session_local) @pytest.fixture diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index 9c95370..81a2a24 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -13,33 +13,54 @@ def write_temp_env(dir_path: Path, filename: str, content: str) -> Path: def test_dev_env_flags(tmp_path: Path, monkeypatch): monkeypatch.setenv("ENV", "dev") - write_temp_env(tmp_path, ".env.dev", "ENV=dev") + # We must provide DATABASE_URL as it's now required + url = "postgresql://user:pass@localhost/db" + monkeypatch.setenv("DATABASE_URL", url) + env_file = write_temp_env(tmp_path, ".env.dev", f"ENV=dev\nDATABASE_URL={url}") - monkeypatch.chdir(tmp_path) - - settings = Settings() + settings = Settings(_env_file=str(env_file)) assert settings.env == "dev" assert settings.is_dev is True assert settings.is_prod is False assert settings.is_demo is False + assert settings.log_level == "DEBUG" + assert "http://localhost:3000" in settings.cors_origins + + +def test_settings_masked_db_url(): + """Test that database password is masked in masked_database_url.""" + url = "postgresql+psycopg2://user:secret_pass@localhost:5432/db" + settings = Settings.model_construct(database_url=url) + + masked = settings.masked_database_url + assert "secret_pass" not in masked + assert "******" in masked + assert "user:******@localhost:5432/db" in masked + + +def test_settings_masked_db_url_no_pass(): + """Test masked_database_url when no password is present.""" + url = "postgresql://localhost/db" + settings = Settings.model_construct(database_url=url) + assert settings.masked_database_url == url def test_invalid_env_rejected(tmp_path: Path, monkeypatch): monkeypatch.setenv("ENV", "staging") - write_temp_env(tmp_path, ".env.staging", "ENV=staging") - - monkeypatch.chdir(tmp_path) + url = "postgresql://localhost/db" + monkeypatch.setenv("DATABASE_URL", url) + env_file = write_temp_env(tmp_path, ".env.staging", f"ENV=staging\nDATABASE_URL={url}") with pytest.raises(ValueError): - Settings() + Settings(_env_file=str(env_file)) def test_fallback_to_dotenv(tmp_path: Path, monkeypatch): monkeypatch.delenv("ENV", raising=False) - write_temp_env(tmp_path, ".env", "ENV=demo") - - monkeypatch.chdir(tmp_path) + url = "postgresql://localhost/db" + monkeypatch.setenv("DATABASE_URL", url) + env_file = write_temp_env(tmp_path, ".env", f"ENV=demo\nDATABASE_URL={url}") - settings = Settings() + settings = Settings(_env_file=str(env_file)) assert settings.env == "demo" assert settings.is_demo is True From 651ec0654902e06fc388db9e0f4790fbf984e7b2 Mon Sep 17 00:00:00 2001 From: Ivan Skvortsov Date: Fri, 1 May 2026 14:02:45 +0200 Subject: [PATCH 2/8] feat(backend): improve server startup and shutdown logic --- backend/app/core/database.py | 7 +------ backend/app/factory.py | 26 ++++++++++++-------------- backend/app/main.py | 16 ++++++++++++---- 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 6885b10..6f57e86 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -15,12 +15,7 @@ def init_db(settings: Settings): """Initialize SQLAlchemy engine and sessionmaker with provided settings.""" global _engine, _SessionLocal - _engine = create_engine( - settings.database_url, - connect_args=( - {"check_same_thread": False} if "sqlite" in settings.database_url else {} - ), - ) + _engine = create_engine(settings.database_url) _SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=_engine) return _engine, _SessionLocal diff --git a/backend/app/factory.py b/backend/app/factory.py index fa9d9c1..58173ba 100644 --- a/backend/app/factory.py +++ b/backend/app/factory.py @@ -1,8 +1,10 @@ +import logging from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy.engine import Engine from sqlalchemy.orm import sessionmaker from starlette.exceptions import HTTPException as StarletteHTTPException @@ -12,17 +14,25 @@ from app.modules.auth.service import create_user_if_not_exists from app.settings import Settings +logger = logging.getLogger(__name__) -def create_app(settings: Settings, SessionLocal: sessionmaker) -> FastAPI: + +def create_app( + settings: Settings, engine: Engine, SessionLocal: sessionmaker +) -> FastAPI: @asynccontextmanager async def lifespan(app: FastAPI): + logger.info(f"Starting application in {settings.env} mode") if settings.is_demo: with SessionLocal() as db: create_user_if_not_exists( db, UserCreate(email="demo@evsy.dev", password="bestructured") ) yield + logger.info("Shutting down application") + engine.dispose() + logger.info("Database connections closed") app = FastAPI( title="Evsy API", @@ -53,21 +63,9 @@ async def lifespan(app: FastAPI): app.state.settings = settings - if settings.is_dev: - print("Running in development mode") - elif settings.is_demo: - print("Running in demo mode") - - allowed_origins = ["http://localhost:5173", "http://localhost:3000"] - allowed_origins = ( - allowed_origins + [settings.frontend_url] - if settings.is_dev - else [settings.frontend_url] - ) - app.add_middleware( CORSMiddleware, - allow_origins=allowed_origins, + allow_origins=settings.cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/backend/app/main.py b/backend/app/main.py index 2469c92..52b67c6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,16 +1,24 @@ +import logging import sys from pydantic import ValidationError from app.core.database import init_db from app.factory import create_app -from app.settings import Settings +from app.settings import get_settings + +settings = get_settings() + +logging.basicConfig( + level=settings.log_level, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + stream=sys.stdout, +) try: - settings = Settings() + engine, SessionLocal = init_db(settings) except ValidationError as e: print("❌ Invalid ENV value in configuration:", e) sys.exit(1) -engine, SessionLocal = init_db(settings) -app = create_app(settings, SessionLocal) +app = create_app(settings, engine, SessionLocal) From 11a2cb6005b1f77cc9c92885462068d90f97a7eb Mon Sep 17 00:00:00 2001 From: Ivan Skvortsov Date: Fri, 1 May 2026 14:02:59 +0200 Subject: [PATCH 3/8] refactor(auth): decentralize settings and improve config security --- backend/app/api/v1/routes/generic.py | 2 +- backend/app/modules/auth/oauth.py | 60 +++++++++++++++------------- backend/app/modules/auth/token.py | 22 +++++----- 3 files changed, 44 insertions(+), 40 deletions(-) diff --git a/backend/app/api/v1/routes/generic.py b/backend/app/api/v1/routes/generic.py index ebe9488..9338616 100644 --- a/backend/app/api/v1/routes/generic.py +++ b/backend/app/api/v1/routes/generic.py @@ -26,7 +26,7 @@ def ping(settings: Settings = Depends(get_settings)): ) def get_config(settings: Settings = Depends(get_settings)): return { - "database_url": settings.database_url, + "database_url": settings.masked_database_url, "debug": settings.is_dev, } diff --git a/backend/app/modules/auth/oauth.py b/backend/app/modules/auth/oauth.py index 44c29c7..a46bfcb 100644 --- a/backend/app/modules/auth/oauth.py +++ b/backend/app/modules/auth/oauth.py @@ -4,9 +4,8 @@ from fastapi import HTTPException from app.modules.auth.schemas import OAuthLogin -from app.settings import Settings +from app.settings import Settings, get_settings -settings = Settings() # --- Provider-specific logic --- @@ -52,36 +51,41 @@ def get_email_from_google(token: str) -> str: # --- Provider config registry --- -OAUTH_PROVIDERS = { - "github": { - "client_id": settings.github_client_id, - "client_secret": settings.github_client_secret, - "auth_url": "https://github.com/login/oauth/authorize", - "token_url": "https://github.com/login/oauth/access_token", - "scope": "user:email", - "email_fetcher": get_email_from_github, - "headers": {"Accept": "application/json"}, - }, - "google": { - "client_id": settings.google_client_id, - "client_secret": settings.google_client_secret, - "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", - "token_url": "https://oauth2.googleapis.com/token", - "scope": "openid email profile", - "email_fetcher": get_email_from_google, - "headers": {"Content-Type": "application/x-www-form-urlencoded"}, - "extra_auth_params": {"access_type": "offline", "prompt": "consent"}, - }, -} + +def get_oauth_config() -> dict: + settings = get_settings() + return { + "github": { + "client_id": settings.github_client_id, + "client_secret": settings.github_client_secret, + "auth_url": "https://github.com/login/oauth/authorize", + "token_url": "https://github.com/login/oauth/access_token", + "scope": "user:email", + "email_fetcher": get_email_from_github, + "headers": {"Accept": "application/json"}, + }, + "google": { + "client_id": settings.google_client_id, + "client_secret": settings.google_client_secret, + "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", + "token_url": "https://oauth2.googleapis.com/token", + "scope": "openid email profile", + "email_fetcher": get_email_from_google, + "headers": {"Content-Type": "application/x-www-form-urlencoded"}, + "extra_auth_params": {"access_type": "offline", "prompt": "consent"}, + }, + } + # --- Generic Logic --- def build_oauth_redirect(provider: str, redirect_uri: str, state: str) -> str: - if provider not in OAUTH_PROVIDERS: + providers = get_oauth_config() + if provider not in providers: raise HTTPException(status_code=400, detail="Unsupported provider") - cfg = OAUTH_PROVIDERS[provider] + cfg = providers[provider] query = { "client_id": cfg["client_id"], "redirect_uri": redirect_uri, @@ -101,10 +105,11 @@ def _post_token_request(url: str, data: dict, headers: dict) -> dict: def exchange_code_for_email(provider: str, code: str, redirect_uri: str) -> str: - if provider not in OAUTH_PROVIDERS: + providers = get_oauth_config() + if provider not in providers: raise HTTPException(status_code=400, detail="Unsupported provider") - cfg = OAUTH_PROVIDERS[provider] + cfg = providers[provider] try: data = { "code": code, @@ -129,5 +134,6 @@ def exchange_code_for_email(provider: str, code: str, redirect_uri: str) -> str: def get_email_from_oauth(login: OAuthLogin) -> str: + settings = get_settings() redirect_uri = f"{settings.backend_url}/api/v1/auth/oauth/callback" return exchange_code_for_email(login.provider, login.token, redirect_uri) diff --git a/backend/app/modules/auth/token.py b/backend/app/modules/auth/token.py index ce4a26c..3ffec29 100644 --- a/backend/app/modules/auth/token.py +++ b/backend/app/modules/auth/token.py @@ -9,30 +9,26 @@ from app.core.database import get_db from app.modules.auth import crud from app.modules.auth.models import User -from app.settings import Settings - -settings = Settings() - -# Secret and settings -SECRET_KEY = settings.secret_key -ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES = 60 +from app.settings import Settings, get_settings # OAuth2 scheme for token dependency oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth/token") def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + settings = get_settings() to_encode = data.copy() expire = datetime.now(UTC) + ( - expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + expires_delta or timedelta(minutes=settings.access_token_expire_minutes) ) to_encode.update({"exp": expire}) - return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return jwt.encode(to_encode, settings.secret_key, algorithm=settings.jwt_algorithm) def get_current_user( - token: str = Depends(oauth2_scheme), db: Session = Depends(get_db) + token: str = Depends(oauth2_scheme), + db: Session = Depends(get_db), + settings: Settings = Depends(get_settings), ) -> User: credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -40,7 +36,9 @@ def get_current_user( headers={"WWW-Authenticate": "Bearer"}, ) try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + payload = jwt.decode( + token, settings.secret_key, algorithms=[settings.jwt_algorithm] + ) email: str = payload.get("sub") if email is None: raise credentials_exception From 5c896ab96782d1cdf2f23755e7fda80758976aa4 Mon Sep 17 00:00:00 2001 From: Ivan Skvortsov Date: Fri, 1 May 2026 15:19:32 +0200 Subject: [PATCH 4/8] style(backend): remove Russian comments and clean up ugly comments --- backend/app/core/database.py | 1 - backend/app/modules/auth/oauth.py | 1 - backend/app/modules/auth/token.py | 1 - backend/app/settings.py | 65 ++++++++----------------------- backend/tests/conftest.py | 2 - backend/tests/test_fields.py | 4 -- backend/tests/test_settings.py | 1 - backend/tests/test_tags.py | 3 -- 8 files changed, 16 insertions(+), 62 deletions(-) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 6f57e86..05d3c8b 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -6,7 +6,6 @@ Base = declarative_base() -# Globals (will be set by init_db) _engine: Engine | None = None _SessionLocal: sessionmaker | None = None diff --git a/backend/app/modules/auth/oauth.py b/backend/app/modules/auth/oauth.py index a46bfcb..8049c29 100644 --- a/backend/app/modules/auth/oauth.py +++ b/backend/app/modules/auth/oauth.py @@ -77,7 +77,6 @@ def get_oauth_config() -> dict: } -# --- Generic Logic --- def build_oauth_redirect(provider: str, redirect_uri: str, state: str) -> str: diff --git a/backend/app/modules/auth/token.py b/backend/app/modules/auth/token.py index 3ffec29..3377e64 100644 --- a/backend/app/modules/auth/token.py +++ b/backend/app/modules/auth/token.py @@ -11,7 +11,6 @@ from app.modules.auth.models import User from app.settings import Settings, get_settings -# OAuth2 scheme for token dependency oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth/token") diff --git a/backend/app/settings.py b/backend/app/settings.py index 8ec3c35..49d66ca 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -1,57 +1,39 @@ import os from functools import lru_cache +from pathlib import Path from typing import Literal, Optional, Any +from dotenv import load_dotenv from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict -import os -from functools import lru_cache -from pathlib import Path -from typing import Literal, Optional - -from dotenv import load_dotenv -from pydantic import Field -from pydantic_settings import BaseSettings, SettingsConfigDict def resolve_env_file() -> Optional[str]: - # 1. Check if ENV is set (e.g., ENV=test) env_mode = os.getenv("ENV") - - # Priority: current working directory (for tests) cwd = Path.cwd() - if env_mode and (cwd / f".env.{env_mode}").exists(): - return str(cwd / f".env.{env_mode}") - if (cwd / ".env").exists(): - return str(cwd / ".env") - - # Fallback: Absolute paths relative to this file + + candidates = [] + if env_mode: + candidates.append(cwd / f".env.{env_mode}") + candidates.append(cwd / ".env") + backend_root = Path(__file__).parent.parent if env_mode: - test_path = backend_root / f".env.{env_mode}" - if test_path.exists(): - return str(test_path) - - local_env = backend_root / ".env" - if local_env.exists(): - return str(local_env) - - root_env = backend_root.parent / ".env" - if root_env.exists(): - return str(root_env) - + candidates.append(backend_root / f".env.{env_mode}") + candidates.append(backend_root / ".env") + + for path in candidates: + if path.exists(): + return str(path) return None class Settings(BaseSettings): - def __init__(self, _env_file: Optional[str] = None, **kwargs): - # Dynamically resolve env file if not provided + def __init__(self, _env_file: Optional[str] = None, **kwargs: Any): if _env_file is None: _env_file = resolve_env_file() - if _env_file: load_dotenv(_env_file, override=True) - super().__init__(**kwargs) env: Literal["dev", "prod", "demo", "test"] = Field(default="dev", alias="ENV") @@ -84,7 +66,6 @@ def __init__(self, _env_file: Optional[str] = None, **kwargs): @model_validator(mode="after") def validate_infrastructure(self) -> "Settings": - """Ensure critical infrastructure settings are provided.""" if not self.database_url: raise ValueError( "DATABASE_URL must be provided in the environment or a .env file" @@ -118,31 +99,17 @@ def cors_origins(self) -> list[str]: @property def masked_database_url(self) -> str: - """Returns the database URL with password masked.""" from urllib.parse import urlparse, urlunparse - if not self.database_url: return "" - parsed = urlparse(self.database_url) - - # If there's no password, return as is if not parsed.password: return self.database_url - - # Reconstruct the netloc (user:pass@host:port) - # We replace only the password part host_port = parsed.hostname or "" if parsed.port: host_port = f"{host_port}:{parsed.port}" - - # Build the masked netloc: user:******@host:port user_part = f"{parsed.username}:******@" if parsed.username else "******@" - new_netloc = f"{user_part}{host_port}" - - # urlunparse reassembles the 6-part tuple: - # (scheme, netloc, path, params, query, fragment) - return urlunparse(parsed._replace(netloc=new_netloc)) + return urlunparse(parsed._replace(netloc=f"{user_part}{host_port}")) @property def available_oauth_providers(self) -> list[str]: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index a549042..c4e162d 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -10,8 +10,6 @@ from app.modules.auth.token import create_access_token from app.settings import Settings -# No need for manual load_dotenv, Settings() will handle it via resolve_env_file() -# or we can pass it explicitly for maximum clarity in tests. @pytest.fixture(scope="session") diff --git a/backend/tests/test_fields.py b/backend/tests/test_fields.py index 591d818..2160d0f 100644 --- a/backend/tests/test_fields.py +++ b/backend/tests/test_fields.py @@ -5,22 +5,18 @@ @pytest.fixture def sample_field(): - """Тестовое событие""" return FieldCreate( name="from_page", description="Some field description.", field_type="string" ) def test_create_field(auth_client, sample_field): - """Тест на создание события""" response = auth_client.post("/v1/fields/", json=sample_field.model_dump()) assert response.status_code == 201 assert response.json()["name"] == "from_page" def test_get_field(auth_client, sample_field): - """Тест на получение события""" - # Create a field first create_response = auth_client.post("/v1/fields/", json=sample_field.model_dump()) field_id = create_response.json()["id"] diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index 81a2a24..482d39a 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -13,7 +13,6 @@ def write_temp_env(dir_path: Path, filename: str, content: str) -> Path: def test_dev_env_flags(tmp_path: Path, monkeypatch): monkeypatch.setenv("ENV", "dev") - # We must provide DATABASE_URL as it's now required url = "postgresql://user:pass@localhost/db" monkeypatch.setenv("DATABASE_URL", url) env_file = write_temp_env(tmp_path, ".env.dev", f"ENV=dev\nDATABASE_URL={url}") diff --git a/backend/tests/test_tags.py b/backend/tests/test_tags.py index 0a41f79..6864aad 100644 --- a/backend/tests/test_tags.py +++ b/backend/tests/test_tags.py @@ -5,7 +5,6 @@ @pytest.fixture def sample_tag(): - """Тестовое событие""" return TagCreate( id="release", description="Some tag description.", @@ -13,14 +12,12 @@ def sample_tag(): def test_create_tag(auth_client, sample_tag): - """Тест на создание события""" response = auth_client.post("/v1/tags/", json=sample_tag.model_dump()) assert response.status_code == 201 assert response.json()["id"] == sample_tag.id def test_get_tag(auth_client, sample_tag): - """Тест на получение события""" auth_client.post("/v1/tags/", json=sample_tag.model_dump()) response = auth_client.get(f"/v1/tags/{sample_tag.id}") assert response.status_code == 200 From af5010b964ec01f39709c8766224c7b97f317b27 Mon Sep 17 00:00:00 2001 From: Ivan Skvortsov Date: Fri, 1 May 2026 15:21:51 +0200 Subject: [PATCH 5/8] chore(backend): apply code formatting and fix linting issues --- backend/app/modules/auth/oauth.py | 5 +---- backend/app/settings.py | 9 +++++---- backend/tests/conftest.py | 1 - backend/tests/test_settings.py | 6 ++++-- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/backend/app/modules/auth/oauth.py b/backend/app/modules/auth/oauth.py index 8049c29..db84e4c 100644 --- a/backend/app/modules/auth/oauth.py +++ b/backend/app/modules/auth/oauth.py @@ -4,8 +4,7 @@ from fastapi import HTTPException from app.modules.auth.schemas import OAuthLogin -from app.settings import Settings, get_settings - +from app.settings import get_settings # --- Provider-specific logic --- @@ -77,8 +76,6 @@ def get_oauth_config() -> dict: } - - def build_oauth_redirect(provider: str, redirect_uri: str, state: str) -> str: providers = get_oauth_config() if provider not in providers: diff --git a/backend/app/settings.py b/backend/app/settings.py index 49d66ca..982731f 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -1,7 +1,7 @@ import os from functools import lru_cache from pathlib import Path -from typing import Literal, Optional, Any +from typing import Any, Literal, Optional from dotenv import load_dotenv from pydantic import Field, model_validator @@ -11,17 +11,17 @@ def resolve_env_file() -> Optional[str]: env_mode = os.getenv("ENV") cwd = Path.cwd() - + candidates = [] if env_mode: candidates.append(cwd / f".env.{env_mode}") candidates.append(cwd / ".env") - + backend_root = Path(__file__).parent.parent if env_mode: candidates.append(backend_root / f".env.{env_mode}") candidates.append(backend_root / ".env") - + for path in candidates: if path.exists(): return str(path) @@ -100,6 +100,7 @@ def cors_origins(self) -> list[str]: @property def masked_database_url(self) -> str: from urllib.parse import urlparse, urlunparse + if not self.database_url: return "" parsed = urlparse(self.database_url) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index c4e162d..299232e 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -11,7 +11,6 @@ from app.settings import Settings - @pytest.fixture(scope="session") def test_settings(): """Session-scoped fixture for test settings, using a Postgres DB.""" diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index 482d39a..0017dad 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -30,7 +30,7 @@ def test_settings_masked_db_url(): """Test that database password is masked in masked_database_url.""" url = "postgresql+psycopg2://user:secret_pass@localhost:5432/db" settings = Settings.model_construct(database_url=url) - + masked = settings.masked_database_url assert "secret_pass" not in masked assert "******" in masked @@ -48,7 +48,9 @@ def test_invalid_env_rejected(tmp_path: Path, monkeypatch): monkeypatch.setenv("ENV", "staging") url = "postgresql://localhost/db" monkeypatch.setenv("DATABASE_URL", url) - env_file = write_temp_env(tmp_path, ".env.staging", f"ENV=staging\nDATABASE_URL={url}") + env_file = write_temp_env( + tmp_path, ".env.staging", f"ENV=staging\nDATABASE_URL={url}" + ) with pytest.raises(ValueError): Settings(_env_file=str(env_file)) From cf66c066c148ba6e32e4bc73e43b1dfa96e2cbf6 Mon Sep 17 00:00:00 2001 From: Ivan Skvortsov Date: Fri, 1 May 2026 17:42:00 +0200 Subject: [PATCH 6/8] chore(ci): prepare CI-specific .env.test to fix database connection --- .github/workflows/backend.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index efc26c9..70468ad 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -48,6 +48,13 @@ jobs: working-directory: ./backend run: poetry install + - name: Prepare .env.test for CI + working-directory: ./backend + run: | + echo "ENV=test" > .env.test + echo "DATABASE_URL=${{ env.DATABASE_URL }}" >> .env.test + echo "SECRET_KEY=ci_secret_key" >> .env.test + - name: Run isort working-directory: ./backend run: poetry run isort . --check-only From a60cfc184bdd5f277aa439c4b02c1ca5da478d68 Mon Sep 17 00:00:00 2001 From: Ivan Skvortsov Date: Fri, 1 May 2026 17:42:41 +0200 Subject: [PATCH 7/8] Makefile cleanup --- backend/Makefile | 12 +----------- backend/app/settings.py | 4 ++-- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/backend/Makefile b/backend/Makefile index c359d91..86775b1 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -26,16 +26,6 @@ test: @echo "Waiting for database to be ready..." @until docker exec evsy-db-test pg_isready -U evsy_test > /dev/null 2>&1; do sleep 1; done @echo "Database is ready. Running tests..." - -@ENV=test poetry run pytest --cov=app --cov-report=term --cov-report=html tests - @echo "Cleaning up..." - @docker rm -f evsy-db-test > /dev/null - -test-fast: - @echo "Starting test database container..." - @docker run --name evsy-db-test -e POSTGRES_USER=evsy_test -e POSTGRES_PASSWORD=evsy_test -e POSTGRES_DB=evsy_test -p 5433:5432 -d postgres:15-alpine > /dev/null - @echo "Waiting for database to be ready..." - @until docker exec evsy-db-test pg_isready -U evsy_test > /dev/null 2>&1; do sleep 1; done - @echo "Database is ready. Running tests..." - -@ENV=test poetry run pytest tests + @poetry run pytest --cov=app --cov-report=term --cov-report=html tests @echo "Cleaning up..." @docker rm -f evsy-db-test > /dev/null \ No newline at end of file diff --git a/backend/app/settings.py b/backend/app/settings.py index 982731f..c72ab98 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -33,7 +33,7 @@ def __init__(self, _env_file: Optional[str] = None, **kwargs: Any): if _env_file is None: _env_file = resolve_env_file() if _env_file: - load_dotenv(_env_file, override=True) + load_dotenv(dotenv_path=_env_file, override=True) super().__init__(**kwargs) env: Literal["dev", "prod", "demo", "test"] = Field(default="dev", alias="ENV") @@ -124,4 +124,4 @@ def available_oauth_providers(self) -> list[str]: @lru_cache() def get_settings() -> Settings: - return Settings() + return Settings() \ No newline at end of file From 20011f52a991f747f3bbc808726ac389b10281a2 Mon Sep 17 00:00:00 2001 From: Ivan Skvortsov Date: Fri, 1 May 2026 17:45:23 +0200 Subject: [PATCH 8/8] style(backend): apply code formatting --- backend/app/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/settings.py b/backend/app/settings.py index c72ab98..5cd5244 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -124,4 +124,4 @@ def available_oauth_providers(self) -> list[str]: @lru_cache() def get_settings() -> Settings: - return Settings() \ No newline at end of file + return Settings()