test_task_crm/tests/services/test_task_service.py

204 lines
6.4 KiB
Python

"""Unit tests for TaskService."""
from __future__ import annotations
import uuid
from collections.abc import AsyncGenerator
from datetime import datetime, timedelta, timezone
import pytest
import pytest_asyncio
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 Deal
from app.models.organization import Organization
from app.models.organization_member import OrganizationMember, OrganizationRole
from app.models.task import TaskCreate
from app.models.user import User
from app.repositories.activity_repo import ActivityRepository
from app.repositories.task_repo import TaskRepository
from app.services.organization_service import OrganizationContext
from app.services.task_service import (
TaskDueDateError,
TaskForbiddenError,
TaskService,
TaskUpdateData,
)
@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)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
yield session
await engine.dispose()
def _make_user(suffix: str) -> User:
return User(
email=f"user-{suffix}@example.com",
hashed_password="hashed",
name="Test User",
is_active=True,
)
async def _setup_environment(
session: AsyncSession,
*,
role: OrganizationRole = OrganizationRole.MANAGER,
context_user: User | None = None,
owner_user: User | None = None,
) -> tuple[OrganizationContext, User, User, int, TaskRepository, ActivityRepository]:
org = Organization(name=f"Org-{uuid.uuid4()}"[:8])
owner = owner_user or _make_user("owner")
ctx_user = context_user or owner
session.add_all([org, owner])
if ctx_user is not owner:
session.add(ctx_user)
await session.flush()
contact = Contact(
organization_id=org.id,
owner_id=owner.id,
name="John Doe",
email="john@example.com",
)
session.add(contact)
await session.flush()
deal = Deal(
organization_id=org.id,
contact_id=contact.id,
owner_id=owner.id,
title="Implementation",
amount=None,
)
session.add(deal)
await session.flush()
membership = OrganizationMember(organization_id=org.id, user_id=ctx_user.id, role=role)
context = OrganizationContext(organization=org, membership=membership)
task_repo = TaskRepository(session=session)
activity_repo = ActivityRepository(session=session)
return context, owner, ctx_user, deal.id, task_repo, activity_repo
@pytest.mark.asyncio
async def test_create_task_logs_activity(session: AsyncSession) -> None:
context, owner, _, deal_id, task_repo, activity_repo = await _setup_environment(session)
service = TaskService(task_repository=task_repo, activity_repository=activity_repo)
due_date = datetime.now(timezone.utc) + timedelta(days=2)
task = await service.create_task(
TaskCreate(
deal_id=deal_id,
title="Follow up",
description="Call client",
due_date=due_date,
),
context=context,
)
result = await session.scalars(select(Activity).where(Activity.deal_id == deal_id))
activities = result.all()
assert len(activities) == 1
assert activities[0].type == ActivityType.TASK_CREATED
assert activities[0].payload["task_id"] == task.id
assert activities[0].payload["title"] == task.title
@pytest.mark.asyncio
async def test_member_cannot_create_task_for_foreign_deal(session: AsyncSession) -> None:
owner = _make_user("owner")
member = _make_user("member")
context, _, _, deal_id, task_repo, activity_repo = await _setup_environment(
session,
role=OrganizationRole.MEMBER,
context_user=member,
owner_user=owner,
)
service = TaskService(task_repository=task_repo, activity_repository=activity_repo)
with pytest.raises(TaskForbiddenError):
await service.create_task(
TaskCreate(
deal_id=deal_id,
title="Follow up",
description=None,
due_date=datetime.now(timezone.utc) + timedelta(days=1),
),
context=context,
)
@pytest.mark.asyncio
async def test_due_date_cannot_be_in_past(session: AsyncSession) -> None:
context, _, _, deal_id, task_repo, activity_repo = await _setup_environment(session)
service = TaskService(task_repository=task_repo, activity_repository=activity_repo)
with pytest.raises(TaskDueDateError):
await service.create_task(
TaskCreate(
deal_id=deal_id,
title="Late",
description=None,
due_date=datetime.now(timezone.utc) - timedelta(days=1),
),
context=context,
)
@pytest.mark.asyncio
async def test_member_cannot_update_foreign_task(session: AsyncSession) -> None:
# First create a task as the owner
owner = _make_user("owner")
context_owner, _, _, deal_id, task_repo, activity_repo = await _setup_environment(
session,
context_user=owner,
owner_user=owner,
)
service = TaskService(task_repository=task_repo, activity_repository=activity_repo)
task = await service.create_task(
TaskCreate(
deal_id=deal_id,
title="Prepare deck",
description=None,
due_date=datetime.now(timezone.utc) + timedelta(days=5),
),
context=context_owner,
)
# Attempt to update it as another member
member = _make_user("member")
session.add(member)
await session.flush()
membership = OrganizationMember(
organization_id=context_owner.organization_id,
user_id=member.id,
role=OrganizationRole.MEMBER,
)
member_context = OrganizationContext(
organization=context_owner.organization,
membership=membership,
)
with pytest.raises(TaskForbiddenError):
await service.update_task(
task.id,
TaskUpdateData(is_done=True),
context=member_context,
)