"""API tests for analytics endpoints.""" from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timedelta, timezone from decimal import Decimal import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.core.security import jwt_service from app.models.contact import Contact from app.models.deal import Deal, DealStage, DealStatus from app.models.organization import Organization from app.models.organization_member import OrganizationMember, OrganizationRole from app.models.user import User @dataclass(slots=True) class AnalyticsScenario: organization_id: int user_id: int user_email: str token: str in_progress_deal_id: int async def prepare_analytics_scenario( session_factory: async_sessionmaker[AsyncSession], ) -> AnalyticsScenario: async with session_factory() as session: org = Organization(name="Analytics Org") user = User( email="analytics@example.com", hashed_password="hashed", name="Analyst", is_active=True, ) session.add_all([org, user]) await session.flush() membership = OrganizationMember( organization_id=org.id, user_id=user.id, role=OrganizationRole.OWNER, ) contact = Contact( organization_id=org.id, owner_id=user.id, name="Client", email="client@example.com", ) session.add_all([membership, contact]) await session.flush() now = datetime.now(timezone.utc) deals = [ Deal( organization_id=org.id, contact_id=contact.id, owner_id=user.id, title="Qual 1", amount=Decimal("100"), status=DealStatus.NEW, stage=DealStage.QUALIFICATION, created_at=now - timedelta(days=5), ), Deal( organization_id=org.id, contact_id=contact.id, owner_id=user.id, title="Proposal", amount=Decimal("200"), status=DealStatus.IN_PROGRESS, stage=DealStage.PROPOSAL, created_at=now - timedelta(days=15), ), Deal( organization_id=org.id, contact_id=contact.id, owner_id=user.id, title="Negotiation Won", amount=Decimal("500"), status=DealStatus.WON, stage=DealStage.NEGOTIATION, created_at=now - timedelta(days=2), ), Deal( organization_id=org.id, contact_id=contact.id, owner_id=user.id, title="Closed Lost", amount=Decimal("300"), status=DealStatus.LOST, stage=DealStage.CLOSED, created_at=now - timedelta(days=40), ), ] session.add_all(deals) await session.commit() token = jwt_service.create_access_token( subject=str(user.id), expires_delta=timedelta(minutes=30), claims={"email": user.email}, ) return AnalyticsScenario( organization_id=org.id, user_id=user.id, user_email=user.email, token=token, in_progress_deal_id=next( deal.id for deal in deals if deal.status is DealStatus.IN_PROGRESS ), ) def _headers(token: str, organization_id: int) -> dict[str, str]: return {"Authorization": f"Bearer {token}", "X-Organization-Id": str(organization_id)} @pytest.mark.asyncio async def test_deals_summary_endpoint_returns_metrics( session_factory: async_sessionmaker[AsyncSession], client: AsyncClient, ) -> None: scenario = await prepare_analytics_scenario(session_factory) response = await client.get( "/api/v1/analytics/deals/summary?days=30", headers=_headers(scenario.token, scenario.organization_id), ) assert response.status_code == 200 payload = response.json() assert payload["total_deals"] == 4 by_status = {entry["status"]: entry for entry in payload["by_status"]} assert by_status[DealStatus.NEW.value]["count"] == 1 assert by_status[DealStatus.WON.value]["amount_sum"] == "500" assert payload["won"]["average_amount"] == "500" assert payload["new_deals"]["count"] == 3 @pytest.mark.asyncio async def test_deals_summary_respects_days_filter( session_factory: async_sessionmaker[AsyncSession], client: AsyncClient, ) -> None: scenario = await prepare_analytics_scenario(session_factory) response = await client.get( "/api/v1/analytics/deals/summary?days=3", headers=_headers(scenario.token, scenario.organization_id), ) assert response.status_code == 200 payload = response.json() assert payload["new_deals"]["count"] == 1 # только сделки моложе трёх дней @pytest.mark.asyncio async def test_deals_funnel_returns_breakdown( session_factory: async_sessionmaker[AsyncSession], client: AsyncClient, ) -> None: scenario = await prepare_analytics_scenario(session_factory) response = await client.get( "/api/v1/analytics/deals/funnel", headers=_headers(scenario.token, scenario.organization_id), ) assert response.status_code == 200 payload = response.json() assert len(payload["stages"]) == 4 qualification = next( item for item in payload["stages"] if item["stage"] == DealStage.QUALIFICATION.value ) assert qualification["total"] == 1 proposal = next(item for item in payload["stages"] if item["stage"] == DealStage.PROPOSAL.value) assert proposal["conversion_to_next"] == 100.0 @pytest.mark.asyncio async def test_deal_update_invalidates_cached_summary( session_factory: async_sessionmaker[AsyncSession], client: AsyncClient, cache_stub, ) -> None: scenario = await prepare_analytics_scenario(session_factory) headers = _headers(scenario.token, scenario.organization_id) first = await client.get( "/api/v1/analytics/deals/summary?days=30", headers=headers, ) assert first.status_code == 200 keys = [key async for key in cache_stub.scan_iter("analytics:summary:*")] assert keys, "cache should contain warmed summary" patch_response = await client.patch( f"/api/v1/deals/{scenario.in_progress_deal_id}", json={"status": DealStatus.WON.value, "stage": DealStage.CLOSED.value}, headers=headers, ) assert patch_response.status_code == 200 refreshed = await client.get( "/api/v1/analytics/deals/summary?days=30", headers=headers, ) assert refreshed.status_code == 200 payload = refreshed.json() assert payload["won"]["count"] == 2