289 lines
11 KiB
Python
289 lines
11 KiB
Python
"""
|
|||
|
|
Copyright 2024-2026 ReplyPier
|
||
|
|
|
||
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||
|
|
you may not use this file except in compliance with the License.
|
||
|
|
You may obtain a copy of the License at
|
||
|
|
|
||
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
||
|
|
|
||
|
|
Unless required by applicable law or agreed to in writing, software
|
||
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
|
|
See the License for the specific language governing permissions and
|
||
|
|
limitations under the License.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from datetime import datetime, timedelta, timezone
|
||
|
|
from unittest.mock import AsyncMock, patch
|
||
|
|
from uuid import uuid4
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from app.models.chat_history import ChatHistory
|
||
|
|
from app.models.rating import Rating
|
||
|
|
from app.models.session_to_agent import SessionStatus, SessionToAgent
|
||
|
|
from app.models.ticket import Ticket, TicketPriority, TicketSource, TicketStatus
|
||
|
|
from app.models.ticket_activity import TicketActivityType, TicketActorType
|
||
|
|
from app.repositories.rating import RatingRepository
|
||
|
|
from app.services.ticket import TicketService
|
||
|
|
from app.services.ticket_csat import record_conversation_rating
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(autouse=True)
|
||
|
|
def no_embeddings():
|
||
|
|
with patch("app.services.ticket.embed_ticket_text", return_value=None):
|
||
|
|
yield
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(autouse=True)
|
||
|
|
def no_delivery():
|
||
|
|
"""CSAT asks go out over the widget socket — irrelevant here."""
|
||
|
|
with patch("app.services.ticket.deliver_to_customer", new=AsyncMock()):
|
||
|
|
yield
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def service(db):
|
||
|
|
return TicketService(db)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def test_session(db, test_organization, test_customer, test_agent) -> SessionToAgent:
|
||
|
|
session = SessionToAgent(
|
||
|
|
session_id=uuid4(),
|
||
|
|
organization_id=test_organization.id,
|
||
|
|
customer_id=test_customer.id,
|
||
|
|
agent_id=test_agent.id,
|
||
|
|
status=SessionStatus.OPEN,
|
||
|
|
channel="web",
|
||
|
|
)
|
||
|
|
db.add(session)
|
||
|
|
db.commit()
|
||
|
|
db.refresh(session)
|
||
|
|
return session
|
||
|
|
|
||
|
|
|
||
|
|
def make_ticket(service, db, org, **overrides) -> Ticket:
|
||
|
|
kwargs = dict(
|
||
|
|
organization_id=org.id,
|
||
|
|
title="Refund never arrived",
|
||
|
|
priority=TicketPriority.MEDIUM,
|
||
|
|
source=TicketSource.MANUAL,
|
||
|
|
)
|
||
|
|
kwargs.update(overrides)
|
||
|
|
ticket, _dupes = service.create_ticket(**kwargs)
|
||
|
|
db.commit()
|
||
|
|
db.refresh(ticket)
|
||
|
|
return ticket
|
||
|
|
|
||
|
|
|
||
|
|
def make_rating(db, session, org, score=5, feedback=None) -> Rating:
|
||
|
|
rating = Rating(
|
||
|
|
session_id=session.session_id,
|
||
|
|
customer_id=session.customer_id,
|
||
|
|
agent_id=session.agent_id,
|
||
|
|
organization_id=org.id,
|
||
|
|
rating=score,
|
||
|
|
feedback=feedback,
|
||
|
|
)
|
||
|
|
db.add(rating)
|
||
|
|
db.commit()
|
||
|
|
db.refresh(rating)
|
||
|
|
return rating
|
||
|
|
|
||
|
|
|
||
|
|
class TestCsatRequest:
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_close_stamps_requested_at(
|
||
|
|
self, service, db, test_organization, test_session
|
||
|
|
):
|
||
|
|
ticket = make_ticket(
|
||
|
|
service, db, test_organization,
|
||
|
|
session_id=test_session.session_id, source=TicketSource.CHAT_AI,
|
||
|
|
)
|
||
|
|
await service.close(ticket)
|
||
|
|
db.commit()
|
||
|
|
assert ticket.csat_requested_at is not None
|
||
|
|
assert ticket.csat_score is None
|
||
|
|
types = [a.activity_type for a in service.activity_repo.list_for_ticket(ticket.id)]
|
||
|
|
assert TicketActivityType.CSAT_REQUESTED.value in types
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_close_asks_with_the_widget_rating_prompt(
|
||
|
|
self, service, db, test_organization, test_session
|
||
|
|
):
|
||
|
|
"""The widget only shows its stars on a message that also ends the chat."""
|
||
|
|
ticket = make_ticket(
|
||
|
|
service, db, test_organization,
|
||
|
|
session_id=test_session.session_id, source=TicketSource.CHAT_AI,
|
||
|
|
)
|
||
|
|
with patch("app.services.ticket.deliver_to_customer", new=AsyncMock()) as deliver:
|
||
|
|
await service.close(ticket)
|
||
|
|
payload = deliver.call_args.args[2]
|
||
|
|
assert payload["request_rating"] is True
|
||
|
|
assert payload["end_chat"] is True
|
||
|
|
assert payload["session_id"] == str(test_session.session_id)
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_the_ask_is_kept_in_the_conversation(
|
||
|
|
self, service, db, test_organization, test_session
|
||
|
|
):
|
||
|
|
"""The ask is part of the thread: agents see it and a reloaded widget shows it."""
|
||
|
|
ticket = make_ticket(
|
||
|
|
service, db, test_organization,
|
||
|
|
session_id=test_session.session_id, source=TicketSource.CHAT_AI,
|
||
|
|
)
|
||
|
|
with patch("app.services.ticket.deliver_to_customer", new=AsyncMock()) as deliver:
|
||
|
|
await service.close(ticket)
|
||
|
|
db.commit()
|
||
|
|
stored = db.query(ChatHistory).filter(ChatHistory.session_id == test_session.session_id).all()
|
||
|
|
assert len(stored) == 1
|
||
|
|
assert ticket.display_number in stored[0].message
|
||
|
|
assert stored[0].attributes["request_rating"] is True
|
||
|
|
assert deliver.call_args.kwargs["message_id"] == stored[0].id
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_close_without_session_never_asks(
|
||
|
|
self, service, db, test_organization
|
||
|
|
):
|
||
|
|
"""Conversation-only by design: a ticket with nothing to rate is never
|
||
|
|
asked, so the UI can show "not requested" rather than a pending CSAT
|
||
|
|
that will never arrive."""
|
||
|
|
ticket = make_ticket(service, db, test_organization)
|
||
|
|
await service.close(ticket)
|
||
|
|
db.commit()
|
||
|
|
assert ticket.csat_requested_at is None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_close_respects_csat_disabled(
|
||
|
|
self, service, db, test_organization, test_session
|
||
|
|
):
|
||
|
|
settings = service.settings_repo.get_or_create(test_organization.id)
|
||
|
|
settings.csat_enabled = False
|
||
|
|
db.commit()
|
||
|
|
ticket = make_ticket(
|
||
|
|
service, db, test_organization,
|
||
|
|
session_id=test_session.session_id, source=TicketSource.CHAT_AI,
|
||
|
|
)
|
||
|
|
await service.close(ticket)
|
||
|
|
db.commit()
|
||
|
|
assert ticket.csat_requested_at is None
|
||
|
|
|
||
|
|
|
||
|
|
class TestCsatCapture:
|
||
|
|
def test_rating_lands_on_the_linked_ticket(
|
||
|
|
self, service, db, test_organization, test_session
|
||
|
|
):
|
||
|
|
ticket = make_ticket(
|
||
|
|
service, db, test_organization,
|
||
|
|
session_id=test_session.session_id, source=TicketSource.CHAT_AI,
|
||
|
|
)
|
||
|
|
rating = make_rating(db, test_session, test_organization, score=4, feedback="Quick fix")
|
||
|
|
|
||
|
|
assert record_conversation_rating(db, rating) == ticket.id
|
||
|
|
db.refresh(ticket)
|
||
|
|
assert ticket.csat_score == 4
|
||
|
|
assert ticket.csat_rating_id == rating.id
|
||
|
|
assert ticket.csat_responded_at is not None
|
||
|
|
|
||
|
|
received = [
|
||
|
|
a for a in service.activity_repo.list_for_ticket(ticket.id)
|
||
|
|
if a.activity_type == TicketActivityType.CSAT_RECEIVED.value
|
||
|
|
]
|
||
|
|
assert len(received) == 1
|
||
|
|
assert received[0].actor_type == TicketActorType.CUSTOMER.value
|
||
|
|
assert "4/5" in received[0].body
|
||
|
|
assert "Quick fix" in received[0].body
|
||
|
|
|
||
|
|
def test_rating_without_a_ticket_is_a_noop(self, db, test_organization, test_session):
|
||
|
|
rating = make_rating(db, test_session, test_organization)
|
||
|
|
assert record_conversation_rating(db, rating) is None
|
||
|
|
|
||
|
|
def test_linkage_failure_does_not_raise(self, db, test_organization, test_session):
|
||
|
|
"""The rating is already stored by the time we get here — a broken
|
||
|
|
linkage must never surface to the customer."""
|
||
|
|
rating = make_rating(db, test_session, test_organization)
|
||
|
|
with patch(
|
||
|
|
"app.repositories.ticket.TicketRepository.get_by_session",
|
||
|
|
side_effect=RuntimeError("boom"),
|
||
|
|
):
|
||
|
|
assert record_conversation_rating(db, rating) is None
|
||
|
|
|
||
|
|
def test_re_rating_replaces_the_score_without_a_second_row(
|
||
|
|
self, service, db, test_organization, test_session
|
||
|
|
):
|
||
|
|
"""The widget can re-show the rating prompt, so a customer can rate
|
||
|
|
twice — one rating row per session, latest score wins."""
|
||
|
|
ticket = make_ticket(
|
||
|
|
service, db, test_organization,
|
||
|
|
session_id=test_session.session_id, source=TicketSource.CHAT_AI,
|
||
|
|
)
|
||
|
|
repo = RatingRepository(db)
|
||
|
|
upsert = dict(
|
||
|
|
session_id=test_session.session_id, customer_id=test_session.customer_id,
|
||
|
|
user_id=None, agent_id=test_session.agent_id,
|
||
|
|
organization_id=test_organization.id,
|
||
|
|
)
|
||
|
|
first = repo.upsert_rating(rating=2, **upsert)
|
||
|
|
record_conversation_rating(db, first)
|
||
|
|
second = repo.upsert_rating(rating=5, **upsert)
|
||
|
|
record_conversation_rating(db, second)
|
||
|
|
|
||
|
|
assert second.id == first.id
|
||
|
|
assert db.query(Rating).filter(Rating.session_id == test_session.session_id).count() == 1
|
||
|
|
db.refresh(ticket)
|
||
|
|
assert ticket.csat_score == 5
|
||
|
|
|
||
|
|
def test_resubmitting_the_same_score_adds_no_activity(
|
||
|
|
self, service, db, test_organization, test_session
|
||
|
|
):
|
||
|
|
"""Every write here appends to the audit feed, and the customer
|
||
|
|
controls how often it fires."""
|
||
|
|
ticket = make_ticket(
|
||
|
|
service, db, test_organization,
|
||
|
|
session_id=test_session.session_id, source=TicketSource.CHAT_AI,
|
||
|
|
)
|
||
|
|
rating = make_rating(db, test_session, test_organization, score=3)
|
||
|
|
record_conversation_rating(db, rating)
|
||
|
|
record_conversation_rating(db, rating)
|
||
|
|
|
||
|
|
received = [
|
||
|
|
a for a in service.activity_repo.list_for_ticket(ticket.id)
|
||
|
|
if a.activity_type == TicketActivityType.CSAT_RECEIVED.value
|
||
|
|
]
|
||
|
|
assert len(received) == 1
|
||
|
|
|
||
|
|
|
||
|
|
class TestCsatStats:
|
||
|
|
def _scored(self, service, db, org, score, resolver, days_ago=1):
|
||
|
|
ticket = make_ticket(service, db, org)
|
||
|
|
ticket.csat_score = score
|
||
|
|
ticket.resolved_by_actor = resolver
|
||
|
|
ticket.csat_responded_at = datetime.now(timezone.utc) - timedelta(days=days_ago)
|
||
|
|
db.commit()
|
||
|
|
return ticket
|
||
|
|
|
||
|
|
def test_splits_ai_and_human(self, service, db, test_organization):
|
||
|
|
self._scored(service, db, test_organization, 5, "ai")
|
||
|
|
self._scored(service, db, test_organization, 4, "ai")
|
||
|
|
self._scored(service, db, test_organization, 2, "user")
|
||
|
|
stats = service.stats(test_organization.id)
|
||
|
|
assert stats["csat_responses"] == 3
|
||
|
|
assert stats["csat_ai_avg"] == 4.5
|
||
|
|
assert stats["csat_human_avg"] == 2.0
|
||
|
|
assert stats["csat_avg"] == pytest.approx(3.67, abs=0.01)
|
||
|
|
|
||
|
|
def test_ignores_responses_outside_the_window(self, service, db, test_organization):
|
||
|
|
self._scored(service, db, test_organization, 5, "ai", days_ago=90)
|
||
|
|
stats = service.stats(test_organization.id)
|
||
|
|
assert stats["csat_responses"] == 0
|
||
|
|
assert stats["csat_avg"] is None
|
||
|
|
assert stats["csat_window_days"] == 30
|
||
|
|
|
||
|
|
def test_no_responses(self, service, db, test_organization):
|
||
|
|
make_ticket(service, db, test_organization)
|
||
|
|
stats = service.stats(test_organization.id)
|
||
|
|
assert stats["csat_responses"] == 0
|
||
|
|
assert stats["csat_ai_avg"] is None
|
||
|
|
assert stats["csat_human_avg"] is None
|