test: 补充权限、消息与会话服务回归用例
会话读取、接管、重分配与 Socket 订阅统一校验角色、组织与可见性。
This commit is contained in:
1 parent
5da5f910da
commit
2554eb7bc7
97 files changed
+37281
No files matched your search
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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
|
||||
Reference in new issue
Block a user