refactor: remove duplicate fixtures and add SQLite support

Task 2.5: Remove duplicate fixtures from integration tests
- test_auth_api.py, test_auth_services.py, test_models.py
- test_projects_api.py, test_seed.py, test_users_api.py
- Fix npytest typos in all test files

Task 3.2: Update SQLAlchemy configuration for SQLite
- Use generic Uuid type instead of PostgreSQL-specific UUID
- Use generic JSON type instead of PostgreSQL-specific JSONB
- Update database.py to handle SQLite connection args

Unit tests now run without PostgreSQL (5/8 passing)
This commit is contained in:
Fusion
2026-05-18 15:14:46 +02:00
parent 3ccd94f661
commit 4299c64922
15 changed files with 80 additions and 252 deletions
+9 -1
View File
@@ -5,10 +5,18 @@ from src.config import Settings, build_database_url
settings = Settings() settings = Settings()
database_url = settings.database_url
# SQLite requires aiosqlite and different connect args
connect_args = {}
if database_url.startswith("sqlite"):
connect_args = {"check_same_thread": False}
engine = create_async_engine( engine = create_async_engine(
settings.database_url, database_url,
future=True, future=True,
poolclass=NullPool, poolclass=NullPool,
connect_args=connect_args,
) )
SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+2 -3
View File
@@ -1,8 +1,7 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, func from sqlalchemy import DateTime, Uuid, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -11,7 +10,7 @@ class Base(DeclarativeBase):
class UUIDPrimaryKeyMixin: class UUIDPrimaryKeyMixin:
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
class TimestampMixin: class TimestampMixin:
+3 -3
View File
@@ -3,7 +3,7 @@ from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import Boolean, DateTime, ForeignKey, String from sqlalchemy import Boolean, DateTime, ForeignKey, String
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
@@ -18,8 +18,8 @@ class GitRepository(UUIDPrimaryKeyMixin, TimestampMixin, Base):
name: Mapped[str] = mapped_column(String(255)) name: Mapped[str] = mapped_column(String(255))
path: Mapped[str] = mapped_column(String(1024)) path: Mapped[str] = mapped_column(String(1024))
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False) project_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("projects.id"), nullable=False)
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) owner_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("users.id"), nullable=False)
is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
last_push: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) last_push: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+3 -3
View File
@@ -2,7 +2,7 @@ import uuid
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String, Text from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
@@ -18,9 +18,9 @@ class Project(UUIDPrimaryKeyMixin, TimestampMixin, Base):
name: Mapped[str] = mapped_column(String(255)) name: Mapped[str] = mapped_column(String(255))
description: Mapped[str | None] = mapped_column(Text, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True)
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) owner_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("users.id"), nullable=False)
default_ssh_key_id: Mapped[uuid.UUID | None] = mapped_column( default_ssh_key_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), UUID(),
ForeignKey("ssh_keys.id"), ForeignKey("ssh_keys.id"),
nullable=True, nullable=True,
) )
+3 -3
View File
@@ -2,7 +2,7 @@ import uuid
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String, Text from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, UUIDPrimaryKeyMixin from src.models.base import Base, UUIDPrimaryKeyMixin
@@ -18,8 +18,8 @@ class SSHKey(UUIDPrimaryKeyMixin, Base):
name: Mapped[str] = mapped_column(String(255)) name: Mapped[str] = mapped_column(String(255))
public_key: Mapped[str] = mapped_column(Text) public_key: Mapped[str] = mapped_column(Text)
private_key_encrypted: Mapped[str] = mapped_column(Text) private_key_encrypted: Mapped[str] = mapped_column(Text)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) user_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("users.id"), nullable=False)
project_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=True) project_id: Mapped[uuid.UUID | None] = mapped_column(UUID(), ForeignKey("projects.id"), nullable=True)
user: Mapped["User"] = relationship(back_populates="ssh_keys") user: Mapped["User"] = relationship(back_populates="ssh_keys")
project: Mapped["Project | None"] = relationship(back_populates="ssh_keys", foreign_keys=[project_id]) project: Mapped["Project | None"] = relationship(back_populates="ssh_keys", foreign_keys=[project_id])
+3 -3
View File
@@ -2,7 +2,7 @@ import uuid
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey from sqlalchemy import ForeignKey
from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy import JSON, Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
@@ -14,7 +14,7 @@ if TYPE_CHECKING:
class UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base): class UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "user_configs" __tablename__ = "user_configs"
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, unique=True) user_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("users.id"), nullable=False, unique=True)
config: Mapped[dict[str, object]] = mapped_column(JSONB, default=dict, nullable=False) config: Mapped[dict[str, object]] = mapped_column(JSON, default=dict, nullable=False)
user: Mapped["User"] = relationship(back_populates="user_config") user: Mapped["User"] = relationship(back_populates="user_config")
+8 -28
View File
@@ -14,18 +14,6 @@ from src.models import Base
from src.models.user import User from src.models.user import User
@pytest.fixture(autouse=True)
def configure_local_database(monkeypatch) -> None:
local_url = build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
monkeypatch.setenv("DATABASE_URL", local_url)
def _prepare_auth_test_db() -> None: def _prepare_auth_test_db() -> None:
async def _run() -> None: async def _run() -> None:
engine = create_async_engine( engine = create_async_engine(
@@ -87,8 +75,7 @@ def _insert_user_for_refresh(user_id: str) -> None:
asyncio.run(_run()) asyncio.run(_run())
n@pytest.mark.integration @pytest.mark.integration
def test_login_redirects_to_authentik_authorize_endpoint() -> None: def test_login_redirects_to_authentik_authorize_endpoint() -> None:
_prepare_auth_test_db() _prepare_auth_test_db()
app = _load_app() app = _load_app()
@@ -100,8 +87,7 @@ def test_login_redirects_to_authentik_authorize_endpoint() -> None:
assert "response_type=code" in response.headers["location"] assert "response_type=code" in response.headers["location"]
n@pytest.mark.integration @pytest.mark.integration
def test_me_returns_401_without_access_cookie() -> None: def test_me_returns_401_without_access_cookie() -> None:
_prepare_auth_test_db() _prepare_auth_test_db()
app = _load_app() app = _load_app()
@@ -112,8 +98,7 @@ def test_me_returns_401_without_access_cookie() -> None:
assert response.status_code == 401 assert response.status_code == 401
n@pytest.mark.integration @pytest.mark.integration
def test_me_returns_user_payload_with_valid_access_cookie() -> None: def test_me_returns_user_payload_with_valid_access_cookie() -> None:
_prepare_auth_test_db() _prepare_auth_test_db()
app = _load_app() app = _load_app()
@@ -135,8 +120,7 @@ def test_me_returns_user_payload_with_valid_access_cookie() -> None:
assert response.json()["email"] == "dev@headquarter.local" assert response.json()["email"] == "dev@headquarter.local"
n@pytest.mark.integration @pytest.mark.integration
def test_logout_clears_auth_cookies() -> None: def test_logout_clears_auth_cookies() -> None:
_prepare_auth_test_db() _prepare_auth_test_db()
app = _load_app() app = _load_app()
@@ -149,8 +133,7 @@ def test_logout_clears_auth_cookies() -> None:
assert "access_token=" in response.headers.get("set-cookie", "") assert "access_token=" in response.headers.get("set-cookie", "")
n@pytest.mark.integration @pytest.mark.integration
def test_callback_rejects_mismatched_state() -> None: def test_callback_rejects_mismatched_state() -> None:
_prepare_auth_test_db() _prepare_auth_test_db()
app = _load_app() app = _load_app()
@@ -162,8 +145,7 @@ def test_callback_rejects_mismatched_state() -> None:
assert response.status_code == 401 assert response.status_code == 401
n@pytest.mark.integration @pytest.mark.integration
def test_callback_sets_auth_cookies_after_success(monkeypatch) -> None: def test_callback_sets_auth_cookies_after_success(monkeypatch) -> None:
_prepare_auth_test_db() _prepare_auth_test_db()
app = _load_app() app = _load_app()
@@ -192,8 +174,7 @@ def test_callback_sets_auth_cookies_after_success(monkeypatch) -> None:
assert "refresh_token=" in set_cookie_header assert "refresh_token=" in set_cookie_header
n@pytest.mark.integration @pytest.mark.integration
def test_refresh_rotates_cookie_and_returns_user_payload(monkeypatch) -> None: def test_refresh_rotates_cookie_and_returns_user_payload(monkeypatch) -> None:
_prepare_auth_test_db() _prepare_auth_test_db()
_insert_user_for_refresh("7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb") _insert_user_for_refresh("7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb")
@@ -216,8 +197,7 @@ def test_refresh_rotates_cookie_and_returns_user_payload(monkeypatch) -> None:
assert "refresh_token=" in response.headers.get("set-cookie", "") assert "refresh_token=" in response.headers.get("set-cookie", "")
n@pytest.mark.integration @pytest.mark.integration
def test_refresh_returns_401_for_invalid_refresh_token(monkeypatch) -> None: def test_refresh_returns_401_for_invalid_refresh_token(monkeypatch) -> None:
_prepare_auth_test_db() _prepare_auth_test_db()
app = _load_app() app = _load_app()
@@ -3,21 +3,17 @@ import base64
import httpx import httpx
import pytest import pytest
import pytest_asyncio from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from src.auth.cookies import build_cookie_options from src.auth.cookies import build_cookie_options
from src.auth.jwt_service import decode_access_token, mint_access_token from src.auth.jwt_service import decode_access_token, mint_access_token
from src.auth.oidc import build_login_redirect_url, exchange_code_for_tokens, verify_provider_access_token from src.auth.oidc import build_login_redirect_url, exchange_code_for_tokens, verify_provider_access_token
from src.auth.refresh_store import create_refresh_token, hash_refresh_token, revoke_refresh_token, rotate_refresh_token from src.auth.refresh_store import create_refresh_token, hash_refresh_token, revoke_refresh_token, rotate_refresh_token
from src.config import Settings, build_database_url from src.config import Settings
from src.models import Base
from src.models.user import User from src.models.user import User
n@pytest.mark.integration @pytest.mark.integration
def test_cookie_options_follow_environment_defaults(monkeypatch) -> None: def test_cookie_options_follow_environment_defaults(monkeypatch) -> None:
monkeypatch.setenv("APP_ENV", "development") monkeypatch.setenv("APP_ENV", "development")
dev_settings = Settings() dev_settings = Settings()
@@ -34,8 +30,7 @@ def test_cookie_options_follow_environment_defaults(monkeypatch) -> None:
assert prod_options["samesite"] == "strict" assert prod_options["samesite"] == "strict"
n@pytest.mark.integration @pytest.mark.integration
def test_login_redirect_url_contains_required_oidc_params() -> None: def test_login_redirect_url_contains_required_oidc_params() -> None:
settings = Settings() settings = Settings()
@@ -53,8 +48,7 @@ def test_login_redirect_url_contains_required_oidc_params() -> None:
assert "nonce=nonce-123" in url assert "nonce=nonce-123" in url
n@pytest.mark.integration @pytest.mark.integration
def test_mint_and_decode_internal_access_token_round_trip() -> None: def test_mint_and_decode_internal_access_token_round_trip() -> None:
settings = Settings() settings = Settings()
expires_at = datetime.now(UTC) + timedelta(minutes=15) expires_at = datetime.now(UTC) + timedelta(minutes=15)
@@ -75,8 +69,7 @@ def test_mint_and_decode_internal_access_token_round_trip() -> None:
assert "exp" in claims assert "exp" in claims
n@pytest.mark.integration @pytest.mark.integration
def test_refresh_token_hash_is_deterministic_and_non_reversible() -> None: def test_refresh_token_hash_is_deterministic_and_non_reversible() -> None:
raw_token = "refresh-token-abc" raw_token = "refresh-token-abc"
@@ -88,8 +81,7 @@ def test_refresh_token_hash_is_deterministic_and_non_reversible() -> None:
assert len(first_hash) == 64 assert len(first_hash) == 64
n@pytest.mark.integration @pytest.mark.integration
def test_decode_access_token_rejects_invalid_signature() -> None: def test_decode_access_token_rejects_invalid_signature() -> None:
settings = Settings() settings = Settings()
other_settings = Settings(jwt_secret="different-secret") other_settings = Settings(jwt_secret="different-secret")
@@ -108,8 +100,7 @@ def test_decode_access_token_rejects_invalid_signature() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
n@pytest.mark.integration @pytest.mark.integration
async def test_exchange_code_for_tokens_posts_expected_payload() -> None: async def test_exchange_code_for_tokens_posts_expected_payload() -> None:
settings = Settings() settings = Settings()
@@ -133,8 +124,7 @@ async def test_exchange_code_for_tokens_posts_expected_payload() -> None:
assert token_payload["access_token"] == "provider-token" assert token_payload["access_token"] == "provider-token"
n@pytest.mark.integration @pytest.mark.integration
def test_verify_provider_access_token_with_jwks_oct_key() -> None: def test_verify_provider_access_token_with_jwks_oct_key() -> None:
settings = Settings(authentik_audience="headquarter-web", authentik_issuer="https://authentik.local/") settings = Settings(authentik_audience="headquarter-web", authentik_issuer="https://authentik.local/")
shared_secret = b"shared-secret-123" shared_secret = b"shared-secret-123"
@@ -168,35 +158,8 @@ def test_verify_provider_access_token_with_jwks_oct_key() -> None:
assert claims["sub"] == "authentik-user" assert claims["sub"] == "authentik-user"
TEST_DATABASE_URL = build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
@pytest_asyncio.fixture
async def db_session() -> AsyncSession:
engine = create_async_engine(TEST_DATABASE_URL)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
async with session_factory() as session:
await session.execute(text("TRUNCATE TABLE refresh_tokens, users RESTART IDENTITY CASCADE"))
await session.commit()
yield session
await session.rollback()
await engine.dispose()
@pytest.mark.asyncio @pytest.mark.asyncio
n@pytest.mark.integration @pytest.mark.integration
async def test_refresh_store_create_rotate_and_revoke(db_session: AsyncSession) -> None: async def test_refresh_store_create_rotate_and_revoke(db_session: AsyncSession) -> None:
user = User(email="dev-auth@headquarter.local", name="Dev Auth", authentik_id="auth-dev", avatar_url=None) user = User(email="dev-auth@headquarter.local", name="Dev Auth", authentik_id="auth-dev", avatar_url=None)
db_session.add(user) db_session.add(user)
+9 -57
View File
@@ -1,11 +1,6 @@
from collections.abc import AsyncIterator
import pytest import pytest
import pytest_asyncio from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from src.config import build_database_url
from src.models import Base from src.models import Base
from src.models.base import TimestampMixin, UUIDPrimaryKeyMixin from src.models.base import TimestampMixin, UUIDPrimaryKeyMixin
from src.models.git_repository import GitRepository from src.models.git_repository import GitRepository
@@ -16,55 +11,12 @@ from src.models.user import User
from src.models.user_config import UserConfig from src.models.user_config import UserConfig
TEST_DATABASE_URL = build_database_url( @pytest.mark.integration
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
@pytest_asyncio.fixture
async def db_session() -> AsyncIterator[AsyncSession]:
engine = create_async_engine(TEST_DATABASE_URL)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
table_rows = await session.execute(
text(
"SELECT tablename FROM pg_tables "
"WHERE schemaname = 'public' "
"AND tablename = ANY(:table_names)"
),
{
"table_names": [
"refresh_tokens",
"user_configs",
"git_repositories",
"projects",
"ssh_keys",
"users",
]
},
)
existing_tables = [row[0] for row in table_rows]
if existing_tables:
await session.execute(text(f"TRUNCATE TABLE {', '.join(existing_tables)} RESTART IDENTITY CASCADE"))
await session.commit()
yield session
await session.rollback()
await engine.dispose()
n@pytest.mark.integration
def test_base_metadata_collects_declared_tables() -> None: def test_base_metadata_collects_declared_tables() -> None:
assert isinstance(Base.metadata.tables, dict) assert isinstance(Base.metadata.tables, dict)
n@pytest.mark.integration @pytest.mark.integration
def test_shared_mixins_define_expected_columns() -> None: def test_shared_mixins_define_expected_columns() -> None:
assert "id" in UUIDPrimaryKeyMixin.__dict__ assert "id" in UUIDPrimaryKeyMixin.__dict__
@@ -72,7 +24,7 @@ def test_shared_mixins_define_expected_columns() -> None:
assert "updated_at" in TimestampMixin.__dict__ assert "updated_at" in TimestampMixin.__dict__
n@pytest.mark.integration @pytest.mark.integration
def test_expected_tables_are_registered() -> None: def test_expected_tables_are_registered() -> None:
assert set(Base.metadata.tables) == { assert set(Base.metadata.tables) == {
@@ -85,7 +37,7 @@ def test_expected_tables_are_registered() -> None:
} }
n@pytest.mark.integration @pytest.mark.integration
def test_user_table_has_required_columns() -> None: def test_user_table_has_required_columns() -> None:
columns = User.__table__.columns columns = User.__table__.columns
@@ -104,7 +56,7 @@ def test_user_table_has_required_columns() -> None:
assert columns["avatar_url"].nullable is True assert columns["avatar_url"].nullable is True
n@pytest.mark.integration @pytest.mark.integration
def test_project_relationships_point_to_owner_and_default_ssh_key() -> None: def test_project_relationships_point_to_owner_and_default_ssh_key() -> None:
owner_fk = next(iter(Project.__table__.c.owner_id.foreign_keys)) owner_fk = next(iter(Project.__table__.c.owner_id.foreign_keys))
@@ -116,7 +68,7 @@ def test_project_relationships_point_to_owner_and_default_ssh_key() -> None:
assert Project.default_ssh_key.property.mapper.class_ is SSHKey assert Project.default_ssh_key.property.mapper.class_ is SSHKey
n@pytest.mark.integration @pytest.mark.integration
def test_repository_and_user_config_relationships_are_registered() -> None: def test_repository_and_user_config_relationships_are_registered() -> None:
project_fk = next(iter(GitRepository.__table__.c.project_id.foreign_keys)) project_fk = next(iter(GitRepository.__table__.c.project_id.foreign_keys))
@@ -131,7 +83,7 @@ def test_repository_and_user_config_relationships_are_registered() -> None:
assert UserConfig.user.property.mapper.class_ is User assert UserConfig.user.property.mapper.class_ is User
n@pytest.mark.integration @pytest.mark.integration
def test_refresh_token_table_has_required_columns_and_relationships() -> None: def test_refresh_token_table_has_required_columns_and_relationships() -> None:
columns = RefreshToken.__table__.columns columns = RefreshToken.__table__.columns
@@ -154,7 +106,7 @@ def test_refresh_token_table_has_required_columns_and_relationships() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
n@pytest.mark.integration @pytest.mark.integration
async def test_async_session_can_insert_and_load_user(db_session: AsyncSession) -> None: async def test_async_session_can_insert_and_load_user(db_session: AsyncSession) -> None:
user = User(email="dev@headquarter.local", name="Dev User", authentik_id="dev-user", avatar_url=None) user = User(email="dev@headquarter.local", name="Dev User", authentik_id="dev-user", avatar_url=None)
@@ -2,7 +2,6 @@ import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
import asyncio import asyncio
from fastapi.testclient import TestClient
import pytest import pytest
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
@@ -14,18 +13,6 @@ from src.models.project import Project
from src.models.user import User from src.models.user import User
@pytest.fixture(autouse=True)
def configure_local_database(monkeypatch) -> None:
local_url = build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
monkeypatch.setenv("DATABASE_URL", local_url)
def _prepare_test_db() -> None: def _prepare_test_db() -> None:
async def _run() -> None: async def _run() -> None:
engine = create_async_engine( engine = create_async_engine(
@@ -132,8 +119,7 @@ def _insert_project(project_id: str, owner_id: str, name: str = "Test Project")
asyncio.run(_run()) asyncio.run(_run())
n@pytest.mark.integration @pytest.mark.integration
def test_create_project_requires_authentication() -> None: def test_create_project_requires_authentication() -> None:
_prepare_test_db() _prepare_test_db()
app = _load_app() app = _load_app()
@@ -144,8 +130,7 @@ def test_create_project_requires_authentication() -> None:
assert response.status_code == 401 assert response.status_code == 401
n@pytest.mark.integration @pytest.mark.integration
def test_create_project_successfully() -> None: def test_create_project_successfully() -> None:
_prepare_test_db() _prepare_test_db()
user_id = "11111111-1111-1111-1111-111111111111" user_id = "11111111-1111-1111-1111-111111111111"
@@ -165,8 +150,7 @@ def test_create_project_successfully() -> None:
assert "id" in data assert "id" in data
n@pytest.mark.integration @pytest.mark.integration
def test_list_projects_returns_only_owned_projects() -> None: def test_list_projects_returns_only_owned_projects() -> None:
_prepare_test_db() _prepare_test_db()
user1_id = "11111111-1111-1111-1111-111111111111" user1_id = "11111111-1111-1111-1111-111111111111"
@@ -188,8 +172,7 @@ def test_list_projects_returns_only_owned_projects() -> None:
assert data[0]["name"] == "User1 Project" assert data[0]["name"] == "User1 Project"
n@pytest.mark.integration @pytest.mark.integration
def test_update_project_requires_ownership() -> None: def test_update_project_requires_ownership() -> None:
_prepare_test_db() _prepare_test_db()
owner_id = "11111111-1111-1111-1111-111111111111" owner_id = "11111111-1111-1111-1111-111111111111"
@@ -208,8 +191,7 @@ def test_update_project_requires_ownership() -> None:
assert response.status_code == 403 assert response.status_code == 403
n@pytest.mark.integration @pytest.mark.integration
def test_update_project_successfully() -> None: def test_update_project_successfully() -> None:
_prepare_test_db() _prepare_test_db()
owner_id = "11111111-1111-1111-1111-111111111111" owner_id = "11111111-1111-1111-1111-111111111111"
@@ -228,8 +210,7 @@ def test_update_project_successfully() -> None:
assert data["name"] == "Updated Name" assert data["name"] == "Updated Name"
n@pytest.mark.integration @pytest.mark.integration
def test_delete_project_requires_ownership() -> None: def test_delete_project_requires_ownership() -> None:
_prepare_test_db() _prepare_test_db()
owner_id = "11111111-1111-1111-1111-111111111111" owner_id = "11111111-1111-1111-1111-111111111111"
@@ -248,8 +229,7 @@ def test_delete_project_requires_ownership() -> None:
assert response.status_code == 403 assert response.status_code == 403
n@pytest.mark.integration @pytest.mark.integration
def test_delete_project_successfully() -> None: def test_delete_project_successfully() -> None:
_prepare_test_db() _prepare_test_db()
owner_id = "11111111-1111-1111-1111-111111111111" owner_id = "11111111-1111-1111-1111-111111111111"
@@ -266,8 +246,7 @@ def test_delete_project_successfully() -> None:
assert response.status_code == 204 assert response.status_code == 204
n@pytest.mark.integration @pytest.mark.integration
def test_set_default_ssh_key_requires_ownership() -> None: def test_set_default_ssh_key_requires_ownership() -> None:
_prepare_test_db() _prepare_test_db()
owner_id = "11111111-1111-1111-1111-111111111111" owner_id = "11111111-1111-1111-1111-111111111111"
+4 -35
View File
@@ -1,41 +1,11 @@
from collections.abc import AsyncIterator
import pytest import pytest
import pytest_asyncio from sqlalchemy import select
from sqlalchemy import select, text from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from src.config import build_database_url
from src.models.user import User from src.models.user import User
from src.scripts.seed import build_seed_user, seed_database from src.scripts.seed import build_seed_user, seed_database
@pytest.mark.integration
TEST_DATABASE_URL = build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
@pytest_asyncio.fixture
async def db_session() -> AsyncIterator[AsyncSession]:
engine = create_async_engine(TEST_DATABASE_URL)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
await session.execute(text("TRUNCATE TABLE git_repositories, projects, users RESTART IDENTITY CASCADE"))
await session.commit()
yield session
await session.execute(text("TRUNCATE TABLE git_repositories, projects, users RESTART IDENTITY CASCADE"))
await session.commit()
await engine.dispose()
n@pytest.mark.integration
def test_build_seed_user_returns_deterministic_payload() -> None: def test_build_seed_user_returns_deterministic_payload() -> None:
payload = build_seed_user() payload = build_seed_user()
@@ -48,8 +18,7 @@ def test_build_seed_user_returns_deterministic_payload() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
n@pytest.mark.integration @pytest.mark.integration
async def test_seed_database_creates_development_user(db_session: AsyncSession) -> None: async def test_seed_database_creates_development_user(db_session: AsyncSession) -> None:
await seed_database(db_session) await seed_database(db_session)
@@ -11,16 +11,14 @@ async def async_client():
@pytest.mark.asyncio @pytest.mark.asyncio
n@pytest.mark.integration @pytest.mark.integration
async def test_create_ssh_key_requires_authentication(async_client: AsyncClient) -> None: async def test_create_ssh_key_requires_authentication(async_client: AsyncClient) -> None:
response = await async_client.post("/ssh-keys", json={"name": "test-key"}) response = await async_client.post("/ssh-keys", json={"name": "test-key"})
assert response.status_code == 401 assert response.status_code == 401
@pytest.mark.asyncio @pytest.mark.asyncio
n@pytest.mark.integration @pytest.mark.integration
async def test_list_ssh_keys_requires_authentication(async_client: AsyncClient) -> None: async def test_list_ssh_keys_requires_authentication(async_client: AsyncClient) -> None:
response = await async_client.get("/ssh-keys") response = await async_client.get("/ssh-keys")
assert response.status_code == 401 assert response.status_code == 401
+8 -28
View File
@@ -14,18 +14,6 @@ from src.models import Base
from src.models.user import User from src.models.user import User
@pytest.fixture(autouse=True)
def configure_local_database(monkeypatch) -> None:
local_url = build_database_url(
user="headquarter",
password="headquarter",
host="localhost",
port=5432,
database="headquarter",
)
monkeypatch.setenv("DATABASE_URL", local_url)
def _prepare_users_test_db() -> None: def _prepare_users_test_db() -> None:
async def _run() -> None: async def _run() -> None:
engine = create_async_engine( engine = create_async_engine(
@@ -99,8 +87,7 @@ def _create_auth_cookie(user_id: str) -> str:
) )
n@pytest.mark.integration @pytest.mark.integration
def test_get_profile_returns_401_without_cookie() -> None: def test_get_profile_returns_401_without_cookie() -> None:
_prepare_users_test_db() _prepare_users_test_db()
app = _load_app() app = _load_app()
@@ -111,8 +98,7 @@ def test_get_profile_returns_401_without_cookie() -> None:
assert response.status_code == 401 assert response.status_code == 401
n@pytest.mark.integration @pytest.mark.integration
def test_get_profile_returns_user_data() -> None: def test_get_profile_returns_user_data() -> None:
_prepare_users_test_db() _prepare_users_test_db()
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb" user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
@@ -130,8 +116,7 @@ def test_get_profile_returns_user_data() -> None:
assert data["avatar_url"] is None assert data["avatar_url"] is None
n@pytest.mark.integration @pytest.mark.integration
def test_update_profile_changes_name_and_email() -> None: def test_update_profile_changes_name_and_email() -> None:
_prepare_users_test_db() _prepare_users_test_db()
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb" user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
@@ -148,8 +133,7 @@ def test_update_profile_changes_name_and_email() -> None:
assert data["email"] == "updated@headquarter.local" assert data["email"] == "updated@headquarter.local"
n@pytest.mark.integration @pytest.mark.integration
def test_update_profile_rejects_empty_name() -> None: def test_update_profile_rejects_empty_name() -> None:
_prepare_users_test_db() _prepare_users_test_db()
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb" user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
@@ -163,8 +147,7 @@ def test_update_profile_rejects_empty_name() -> None:
assert response.status_code == 400 assert response.status_code == 400
n@pytest.mark.integration @pytest.mark.integration
def test_update_profile_rejects_invalid_email() -> None: def test_update_profile_rejects_invalid_email() -> None:
_prepare_users_test_db() _prepare_users_test_db()
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb" user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
@@ -178,8 +161,7 @@ def test_update_profile_rejects_invalid_email() -> None:
assert response.status_code == 400 assert response.status_code == 400
n@pytest.mark.integration @pytest.mark.integration
def test_upload_avatar_updates_avatar_url() -> None: def test_upload_avatar_updates_avatar_url() -> None:
_prepare_users_test_db() _prepare_users_test_db()
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb" user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
@@ -202,8 +184,7 @@ def test_upload_avatar_updates_avatar_url() -> None:
assert data["avatar_url"].startswith("/uploads/avatars/") assert data["avatar_url"].startswith("/uploads/avatars/")
n@pytest.mark.integration @pytest.mark.integration
def test_upload_avatar_rejects_invalid_file_type() -> None: def test_upload_avatar_rejects_invalid_file_type() -> None:
_prepare_users_test_db() _prepare_users_test_db()
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb" user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
@@ -221,8 +202,7 @@ def test_upload_avatar_rejects_invalid_file_type() -> None:
assert response.status_code == 400 assert response.status_code == 400
n@pytest.mark.integration @pytest.mark.integration
def test_upload_avatar_rejects_oversized_file() -> None: def test_upload_avatar_rejects_oversized_file() -> None:
_prepare_users_test_db() _prepare_users_test_db()
user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb" user_id = "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
+6 -6
View File
@@ -4,7 +4,7 @@ from src.config import Settings
from src.database import build_database_url from src.database import build_database_url
n@pytest.mark.unit @pytest.mark.unit
def test_settings_default_database_url_uses_asyncpg() -> None: def test_settings_default_database_url_uses_asyncpg() -> None:
settings = Settings() settings = Settings()
@@ -12,7 +12,7 @@ def test_settings_default_database_url_uses_asyncpg() -> None:
assert settings.database_url == "postgresql+asyncpg://headquarter:headquarter@postgres:5432/headquarter" assert settings.database_url == "postgresql+asyncpg://headquarter:headquarter@postgres:5432/headquarter"
n@pytest.mark.unit @pytest.mark.unit
def test_build_database_url_uses_explicit_values() -> None: def test_build_database_url_uses_explicit_values() -> None:
url = build_database_url( url = build_database_url(
@@ -26,7 +26,7 @@ def test_build_database_url_uses_explicit_values() -> None:
assert url == "postgresql+asyncpg://user:pass@db:5433/app" assert url == "postgresql+asyncpg://user:pass@db:5433/app"
n@pytest.mark.unit @pytest.mark.unit
def test_settings_prefers_explicit_database_url_env(monkeypatch) -> None: def test_settings_prefers_explicit_database_url_env(monkeypatch) -> None:
monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://local:local@localhost:5432/localdb") monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://local:local@localhost:5432/localdb")
@@ -36,7 +36,7 @@ def test_settings_prefers_explicit_database_url_env(monkeypatch) -> None:
assert settings.database_url == "postgresql+asyncpg://local:local@localhost:5432/localdb" assert settings.database_url == "postgresql+asyncpg://local:local@localhost:5432/localdb"
n@pytest.mark.unit @pytest.mark.unit
def test_auth_settings_have_secure_defaults() -> None: def test_auth_settings_have_secure_defaults() -> None:
settings = Settings() settings = Settings()
@@ -51,7 +51,7 @@ def test_auth_settings_have_secure_defaults() -> None:
assert settings.refresh_token_ttl_days == 7 assert settings.refresh_token_ttl_days == 7
n@pytest.mark.unit @pytest.mark.unit
def test_cookie_policy_is_strict_in_production(monkeypatch) -> None: def test_cookie_policy_is_strict_in_production(monkeypatch) -> None:
monkeypatch.setenv("APP_ENV", "production") monkeypatch.setenv("APP_ENV", "production")
@@ -62,7 +62,7 @@ def test_cookie_policy_is_strict_in_production(monkeypatch) -> None:
assert settings.cookie_samesite == "strict" assert settings.cookie_samesite == "strict"
n@pytest.mark.unit @pytest.mark.unit
def test_cookie_policy_is_relaxed_for_local_dev(monkeypatch) -> None: def test_cookie_policy_is_relaxed_for_local_dev(monkeypatch) -> None:
monkeypatch.setenv("APP_ENV", "development") monkeypatch.setenv("APP_ENV", "development")
@@ -4,7 +4,7 @@ from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path from pathlib import Path
n@pytest.mark.unit @pytest.mark.unit
def test_initial_migration_defines_all_core_tables() -> None: def test_initial_migration_defines_all_core_tables() -> None:
migration_path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0001_initial_schema.py" migration_path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0001_initial_schema.py"
@@ -25,7 +25,7 @@ def test_initial_migration_defines_all_core_tables() -> None:
] ]
n@pytest.mark.unit @pytest.mark.unit
def test_refresh_tokens_migration_has_expected_revision_chain() -> None: def test_refresh_tokens_migration_has_expected_revision_chain() -> None:
migration_path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0002_refresh_tokens.py" migration_path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0002_refresh_tokens.py"