diff --git a/app/core/database.py b/app/core/database.py index be4cf83..e0d2820 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -14,4 +14,9 @@ AsyncSessionMaker = async_sessionmaker(bind=engine, expire_on_commit=False) async def get_session() -> AsyncGenerator[AsyncSession, None]: """Yield an async database session for request scope.""" async with AsyncSessionMaker() as session: - yield session + try: + yield session + await session.commit() + except Exception: # pragma: no cover - defensive cleanup + await session.rollback() + raise diff --git a/app/repositories/deal_repo.py b/app/repositories/deal_repo.py index 1f03ae0..a944716 100644 --- a/app/repositories/deal_repo.py +++ b/app/repositories/deal_repo.py @@ -108,6 +108,7 @@ class DealRepository: if hasattr(deal, field): setattr(deal, field, value) await self._session.flush() + await self._session.refresh(deal) return deal def _apply_filters( diff --git a/tests/api/v1/conftest.py b/tests/api/v1/conftest.py index 61c6611..8c8fcb6 100644 --- a/tests/api/v1/conftest.py +++ b/tests/api/v1/conftest.py @@ -3,15 +3,31 @@ from __future__ import annotations from collections.abc import AsyncGenerator +import pytest import pytest_asyncio from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from app.api.deps import get_db_session +from app.core.security import password_hasher from app.main import create_app from app.models import Base +@pytest.fixture(autouse=True) +def stub_password_hasher(monkeypatch: pytest.MonkeyPatch) -> None: + """Replace bcrypt-dependent hashing with deterministic helpers for tests.""" + + def fake_hash(password: str) -> str: + return f"hashed-{password}" + + def fake_verify(password: str, hashed_password: str) -> bool: + return hashed_password == f"hashed-{password}" + + monkeypatch.setattr(password_hasher, "hash", fake_hash) + monkeypatch.setattr(password_hasher, "verify", fake_verify) + + @pytest_asyncio.fixture() async def session_factory() -> AsyncGenerator[async_sessionmaker[AsyncSession], None]: engine = create_async_engine("sqlite+aiosqlite:///:memory:", future=True) @@ -30,7 +46,12 @@ async def client( async def _get_session_override() -> AsyncGenerator[AsyncSession, None]: async with session_factory() as session: - yield session + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise app.dependency_overrides[get_db_session] = _get_session_override transport = ASGITransport(app=app) diff --git a/tests/api/v1/test_auth.py b/tests/api/v1/test_auth.py new file mode 100644 index 0000000..b81c66d --- /dev/null +++ b/tests/api/v1/test_auth.py @@ -0,0 +1,100 @@ +"""API tests for authentication endpoints.""" +from __future__ import annotations + +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.core.security import password_hasher +from app.models.organization import Organization +from app.models.organization_member import OrganizationMember, OrganizationRole +from app.models.user import User + + +@pytest.mark.asyncio +async def test_register_user_creates_organization_membership( + session_factory: async_sessionmaker[AsyncSession], + client: AsyncClient, +) -> None: + payload = { + "email": "new-owner@example.com", + "password": "StrongPass123!", + "name": "Alice Owner", + "organization_name": "Rocket LLC", + } + + response = await client.post("/api/v1/auth/register", json=payload) + + assert response.status_code == 201 + body = response.json() + assert body["token_type"] == "bearer" + assert "access_token" in body + + async with session_factory() as session: + user = await session.scalar(select(User).where(User.email == payload["email"])) + assert user is not None + + organization = await session.scalar( + select(Organization).where(Organization.name == payload["organization_name"]) + ) + assert organization is not None + + membership = await session.scalar( + select(OrganizationMember).where( + OrganizationMember.organization_id == organization.id, + OrganizationMember.user_id == user.id, + ) + ) + assert membership is not None + assert membership.role == OrganizationRole.OWNER + + +@pytest.mark.asyncio +async def test_login_endpoint_returns_token_for_valid_credentials( + session_factory: async_sessionmaker[AsyncSession], + client: AsyncClient, +) -> None: + async with session_factory() as session: + user = User( + email="login-user@example.com", + hashed_password=password_hasher.hash("Secret123!"), + name="Login User", + is_active=True, + ) + session.add(user) + await session.commit() + + response = await client.post( + "/api/v1/auth/login", + json={"email": "login-user@example.com", "password": "Secret123!"}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["token_type"] == "bearer" + assert "access_token" in body + + +@pytest.mark.asyncio +async def test_token_endpoint_rejects_invalid_credentials( + session_factory: async_sessionmaker[AsyncSession], + client: AsyncClient, +) -> None: + async with session_factory() as session: + user = User( + email="token-user@example.com", + hashed_password=password_hasher.hash("SuperSecret123"), + name="Token User", + is_active=True, + ) + session.add(user) + await session.commit() + + response = await client.post( + "/api/v1/auth/token", + json={"email": "token-user@example.com", "password": "wrong-pass"}, + ) + + assert response.status_code == 401 + assert response.json()["detail"] == "Invalid email or password" diff --git a/tests/api/v1/test_deals.py b/tests/api/v1/test_deals.py new file mode 100644 index 0000000..535b4a6 --- /dev/null +++ b/tests/api/v1/test_deals.py @@ -0,0 +1,113 @@ +"""API tests for deal endpoints.""" +from __future__ import annotations + +from decimal import Decimal + +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.models.activity import Activity, ActivityType +from app.models.deal import Deal, DealStage, DealStatus + +from tests.api.v1.task_activity_shared import auth_headers, make_token, prepare_scenario + + +@pytest.mark.asyncio +async def test_create_deal_endpoint_uses_context_owner( + session_factory: async_sessionmaker[AsyncSession], + client: AsyncClient, +) -> None: + scenario = await prepare_scenario(session_factory) + token = make_token(scenario.user_id, scenario.user_email) + + response = await client.post( + "/api/v1/deals/", + json={ + "contact_id": scenario.contact_id, + "title": "Upsell Subscription", + "amount": 2500.0, + "currency": "USD", + }, + headers=auth_headers(token, scenario), + ) + + assert response.status_code == 201 + payload = response.json() + assert payload["owner_id"] == scenario.user_id + assert payload["organization_id"] == scenario.organization_id + assert payload["title"] == "Upsell Subscription" + + +@pytest.mark.asyncio +async def test_list_deals_endpoint_filters_by_status( + session_factory: async_sessionmaker[AsyncSession], + client: AsyncClient, +) -> None: + scenario = await prepare_scenario(session_factory) + token = make_token(scenario.user_id, scenario.user_email) + + async with session_factory() as session: + base_deal = await session.get(Deal, scenario.deal_id) + assert base_deal is not None + base_deal.status = DealStatus.NEW + + won_deal = Deal( + organization_id=scenario.organization_id, + contact_id=scenario.contact_id, + owner_id=scenario.user_id, + title="Enterprise Upgrade", + amount=Decimal("8000"), + currency="USD", + status=DealStatus.WON, + stage=DealStage.CLOSED, + ) + session.add(won_deal) + await session.commit() + + response = await client.get( + "/api/v1/deals/?status=won", + headers=auth_headers(token, scenario), + ) + + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["title"] == "Enterprise Upgrade" + assert data[0]["status"] == DealStatus.WON.value + + +@pytest.mark.asyncio +async def test_update_deal_endpoint_updates_stage_and_logs_activity( + session_factory: async_sessionmaker[AsyncSession], + client: AsyncClient, +) -> None: + scenario = await prepare_scenario(session_factory) + token = make_token(scenario.user_id, scenario.user_email) + + response = await client.patch( + f"/api/v1/deals/{scenario.deal_id}", + json={ + "stage": DealStage.PROPOSAL.value, + "status": DealStatus.WON.value, + "amount": 5000.0, + "currency": "USD", + }, + headers=auth_headers(token, scenario), + ) + + assert response.status_code == 200 + body = response.json() + assert body["stage"] == DealStage.PROPOSAL.value + assert body["status"] == DealStatus.WON.value + assert Decimal(body["amount"]) == Decimal("5000") + + async with session_factory() as session: + activity_types = await session.scalars( + select(Activity.type).where(Activity.deal_id == scenario.deal_id) + ) + collected = set(activity_types.all()) + + assert ActivityType.STAGE_CHANGED in collected + assert ActivityType.STATUS_CHANGED in collected diff --git a/tests/api/v1/test_users.py b/tests/api/v1/test_users.py new file mode 100644 index 0000000..447f52d --- /dev/null +++ b/tests/api/v1/test_users.py @@ -0,0 +1,88 @@ +"""API tests for user endpoints.""" +from __future__ import annotations + +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.core.security import password_hasher +from app.models.user import User + + +async def _seed_user( + session_factory: async_sessionmaker[AsyncSession], + *, + email: str, + name: str, +) -> int: + async with session_factory() as session: + user = User( + email=email, + hashed_password=password_hasher.hash("SeedPass123!"), + name=name, + is_active=True, + ) + session.add(user) + await session.commit() + return user.id + + +@pytest.mark.asyncio +async def test_create_user_endpoint_persists_user( + session_factory: async_sessionmaker[AsyncSession], + client: AsyncClient, +) -> None: + payload = { + "email": "api-user@example.com", + "name": "API User", + "password": "UserPass123!", + } + + response = await client.post("/api/v1/users/", json=payload) + + assert response.status_code == 201 + body = response.json() + assert body["email"] == payload["email"] + + async with session_factory() as session: + user = await session.get(User, body["id"]) + assert user is not None + assert user.email == payload["email"] + assert user.hashed_password != payload["password"] + + +@pytest.mark.asyncio +async def test_list_users_endpoint_returns_existing_users( + session_factory: async_sessionmaker[AsyncSession], + client: AsyncClient, +) -> None: + await _seed_user(session_factory, email="list-a@example.com", name="List A") + await _seed_user(session_factory, email="list-b@example.com", name="List B") + + response = await client.get("/api/v1/users/") + + assert response.status_code == 200 + data = response.json() + emails = {item["email"] for item in data} + assert {"list-a@example.com", "list-b@example.com"}.issubset(emails) + + +@pytest.mark.asyncio +async def test_get_user_endpoint_returns_single_user( + session_factory: async_sessionmaker[AsyncSession], + client: AsyncClient, +) -> None: + user_id = await _seed_user(session_factory, email="detail@example.com", name="Detail User") + + response = await client.get(f"/api/v1/users/{user_id}") + + assert response.status_code == 200 + payload = response.json() + assert payload["id"] == user_id + assert payload["email"] == "detail@example.com" + + +@pytest.mark.asyncio +async def test_get_user_endpoint_returns_404_for_missing_user(client: AsyncClient) -> None: + response = await client.get("/api/v1/users/999") + assert response.status_code == 404 \ No newline at end of file