feat: add JSONBCompat type for cross-database JSON support and implement ActivityType updates
Test / test (push) Successful in 12s
Test / test (push) Successful in 12s
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
"""Unit tests for DealService."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from decimal import Decimal
|
||||
import uuid
|
||||
|
||||
import pytest # type: ignore[import-not-found]
|
||||
import pytest_asyncio # type: ignore[import-not-found]
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.models.activity import Activity, ActivityType
|
||||
from app.models.base import Base
|
||||
from app.models.contact import Contact
|
||||
from app.models.deal import DealCreate, DealStage, DealStatus
|
||||
from app.models.organization import Organization
|
||||
from app.models.organization_member import OrganizationMember, OrganizationRole
|
||||
from app.models.user import User
|
||||
from app.repositories.deal_repo import DealRepository
|
||||
from app.services.deal_service import (
|
||||
ContactHasDealsError,
|
||||
DealOrganizationMismatchError,
|
||||
DealService,
|
||||
DealStageTransitionError,
|
||||
DealStatusValidationError,
|
||||
DealUpdateData,
|
||||
)
|
||||
from app.services.organization_service import OrganizationContext
|
||||
|
||||
|
||||
@pytest_asyncio.fixture()
|
||||
async def session() -> AsyncGenerator[AsyncSession, None]:
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
future=True,
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
async_session = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def _make_organization(name: str) -> Organization:
|
||||
org = Organization(name=name)
|
||||
return org
|
||||
|
||||
|
||||
def _make_user(email_suffix: str) -> User:
|
||||
return User(
|
||||
email=f"user-{email_suffix}@example.com",
|
||||
hashed_password="hashed",
|
||||
name="Test User",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
|
||||
def _make_context(org: Organization, user: User, role: OrganizationRole) -> OrganizationContext:
|
||||
membership = OrganizationMember(organization_id=org.id, user_id=user.id, role=role)
|
||||
return OrganizationContext(organization=org, membership=membership)
|
||||
|
||||
|
||||
async def _persist_base(session: AsyncSession, *, role: OrganizationRole = OrganizationRole.MANAGER) -> tuple[
|
||||
OrganizationContext,
|
||||
Contact,
|
||||
DealRepository,
|
||||
]:
|
||||
org = _make_organization(name=f"Org-{uuid.uuid4()}"[:8])
|
||||
user = _make_user(email_suffix=str(uuid.uuid4())[:8])
|
||||
session.add_all([org, user])
|
||||
await session.flush()
|
||||
|
||||
contact = Contact(
|
||||
organization_id=org.id,
|
||||
owner_id=user.id,
|
||||
name="John Doe",
|
||||
email="john@example.com",
|
||||
)
|
||||
session.add(contact)
|
||||
await session.flush()
|
||||
|
||||
context = _make_context(org, user, role)
|
||||
repo = DealRepository(session=session)
|
||||
return context, contact, repo
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_deal_rejects_foreign_contact(session: AsyncSession) -> None:
|
||||
context, contact, repo = await _persist_base(session)
|
||||
|
||||
other_org = _make_organization(name="Other")
|
||||
other_user = _make_user(email_suffix="other")
|
||||
session.add_all([other_org, other_user])
|
||||
await session.flush()
|
||||
|
||||
service = DealService(repository=repo)
|
||||
payload = DealCreate(
|
||||
organization_id=other_org.id,
|
||||
contact_id=contact.id,
|
||||
owner_id=context.user_id,
|
||||
title="Website Redesign",
|
||||
amount=None,
|
||||
)
|
||||
|
||||
other_context = _make_context(other_org, other_user, OrganizationRole.MANAGER)
|
||||
|
||||
with pytest.raises(DealOrganizationMismatchError):
|
||||
await service.create_deal(payload, context=other_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stage_rollback_requires_admin(session: AsyncSession) -> None:
|
||||
context, contact, repo = await _persist_base(session, role=OrganizationRole.MANAGER)
|
||||
service = DealService(repository=repo)
|
||||
|
||||
deal = await service.create_deal(
|
||||
DealCreate(
|
||||
organization_id=context.organization_id,
|
||||
contact_id=contact.id,
|
||||
owner_id=context.user_id,
|
||||
title="Migration",
|
||||
amount=Decimal("5000"),
|
||||
),
|
||||
context=context,
|
||||
)
|
||||
deal.stage = DealStage.PROPOSAL
|
||||
|
||||
with pytest.raises(DealStageTransitionError):
|
||||
await service.update_deal(
|
||||
deal,
|
||||
DealUpdateData(stage=DealStage.QUALIFICATION),
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stage_rollback_allowed_for_admin(session: AsyncSession) -> None:
|
||||
context, contact, repo = await _persist_base(session, role=OrganizationRole.ADMIN)
|
||||
service = DealService(repository=repo)
|
||||
|
||||
deal = await service.create_deal(
|
||||
DealCreate(
|
||||
organization_id=context.organization_id,
|
||||
contact_id=contact.id,
|
||||
owner_id=context.user_id,
|
||||
title="Rollout",
|
||||
amount=Decimal("1000"),
|
||||
),
|
||||
context=context,
|
||||
)
|
||||
deal.stage = DealStage.NEGOTIATION
|
||||
|
||||
updated = await service.update_deal(
|
||||
deal,
|
||||
DealUpdateData(stage=DealStage.PROPOSAL),
|
||||
context=context,
|
||||
)
|
||||
|
||||
assert updated.stage == DealStage.PROPOSAL
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_won_requires_positive_amount(session: AsyncSession) -> None:
|
||||
context, contact, repo = await _persist_base(session)
|
||||
service = DealService(repository=repo)
|
||||
|
||||
deal = await service.create_deal(
|
||||
DealCreate(
|
||||
organization_id=context.organization_id,
|
||||
contact_id=contact.id,
|
||||
owner_id=context.user_id,
|
||||
title="Zero",
|
||||
amount=None,
|
||||
),
|
||||
context=context,
|
||||
)
|
||||
|
||||
with pytest.raises(DealStatusValidationError):
|
||||
await service.update_deal(
|
||||
deal,
|
||||
DealUpdateData(status=DealStatus.WON),
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updates_create_activity_records(session: AsyncSession) -> None:
|
||||
context, contact, repo = await _persist_base(session)
|
||||
service = DealService(repository=repo)
|
||||
|
||||
deal = await service.create_deal(
|
||||
DealCreate(
|
||||
organization_id=context.organization_id,
|
||||
contact_id=contact.id,
|
||||
owner_id=context.user_id,
|
||||
title="Activity",
|
||||
amount=Decimal("100"),
|
||||
),
|
||||
context=context,
|
||||
)
|
||||
|
||||
await service.update_deal(
|
||||
deal,
|
||||
DealUpdateData(
|
||||
stage=DealStage.PROPOSAL,
|
||||
status=DealStatus.WON,
|
||||
amount=Decimal("5000"),
|
||||
),
|
||||
context=context,
|
||||
)
|
||||
|
||||
result = await session.scalars(select(Activity).where(Activity.deal_id == deal.id))
|
||||
activity_types = {activity.type for activity in result.all()}
|
||||
assert ActivityType.STAGE_CHANGED in activity_types
|
||||
assert ActivityType.STATUS_CHANGED in activity_types
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_contact_delete_guard(session: AsyncSession) -> None:
|
||||
context, contact, repo = await _persist_base(session)
|
||||
service = DealService(repository=repo)
|
||||
|
||||
deal = await service.create_deal(
|
||||
DealCreate(
|
||||
organization_id=context.organization_id,
|
||||
contact_id=contact.id,
|
||||
owner_id=context.user_id,
|
||||
title="To Delete",
|
||||
amount=Decimal("100"),
|
||||
),
|
||||
context=context,
|
||||
)
|
||||
|
||||
with pytest.raises(ContactHasDealsError):
|
||||
await service.ensure_contact_can_be_deleted(contact.id)
|
||||
|
||||
await session.delete(deal)
|
||||
await session.flush()
|
||||
|
||||
await service.ensure_contact_can_be_deleted(contact.id)
|
||||
Reference in New Issue
Block a user