Files
replypier/backend/tests/services/test_ticket_csat.py
T

289 lines
11 KiB
Python
Raw Normal View History

"""
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