2026-10-08 22:35:00 +08:00
|
|
|
"""
|
|
|
|
|
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 unittest.mock import patch
|
2026-10-29 09:46:21 +08:00
|
|
|
from datetime import datetime, timezone
|
2026-10-08 22:35:00 +08:00
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from app.models.chat_history import ChatHistory
|
|
|
|
|
from app.models.session_to_agent import SessionStatus, SessionToAgent
|
|
|
|
|
from app.services.message_delivery import DeliveryResult
|
|
|
|
|
from app.models.ticket import (
|
|
|
|
|
Ticket,
|
|
|
|
|
TicketPriority,
|
|
|
|
|
TicketSource,
|
|
|
|
|
TicketStatus,
|
|
|
|
|
)
|
2026-10-29 09:46:21 +08:00
|
|
|
from app.models.ticket_activity import TicketActivityType, TicketActorType
|
2026-10-08 22:35:00 +08:00
|
|
|
from app.services.ticket import NATIVE_INTEGRATION_TYPE, TicketService, render_customer_template
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
def no_embeddings():
|
|
|
|
|
"""Embeddings hit the local FastEmbed model — irrelevant to these tests."""
|
|
|
|
|
with patch("app.services.ticket.embed_ticket_text", return_value=None):
|
|
|
|
|
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="Payout delayed for merchant #8842",
|
|
|
|
|
description="Batch stuck in processing.",
|
|
|
|
|
priority=TicketPriority.HIGH,
|
|
|
|
|
source=TicketSource.MANUAL,
|
|
|
|
|
)
|
|
|
|
|
kwargs.update(overrides)
|
|
|
|
|
ticket, _dupes = service.create_ticket(**kwargs)
|
|
|
|
|
db.commit()
|
|
|
|
|
db.refresh(ticket)
|
|
|
|
|
return ticket
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTicketCreation:
|
|
|
|
|
def test_a_chat_ticket_can_leave_the_sessions_customer_off(self, service, db, test_organization, test_session):
|
|
|
|
|
"""A visitor bound to a customer by an email they typed: the ticket must not
|
|
|
|
|
carry that customer's identity to the investigator."""
|
|
|
|
|
ticket = make_ticket(service, db, test_organization, session_id=test_session.session_id,
|
|
|
|
|
source=TicketSource.CHAT_AI, link_session_customer=False)
|
|
|
|
|
|
|
|
|
|
assert ticket.customer_id is None
|
|
|
|
|
|
|
|
|
|
def test_numbers_are_sequential_per_org(self, service, db, test_organization):
|
|
|
|
|
first = make_ticket(service, db, test_organization)
|
|
|
|
|
second = make_ticket(service, db, test_organization)
|
|
|
|
|
assert first.ticket_number == 1
|
|
|
|
|
assert second.ticket_number == 2
|
|
|
|
|
assert second.display_number == "TKT-2"
|
|
|
|
|
|
|
|
|
|
def test_create_links_session_and_mirrors_columns(
|
|
|
|
|
self, service, db, test_organization, test_session
|
|
|
|
|
):
|
|
|
|
|
ticket = make_ticket(
|
|
|
|
|
service, db, test_organization,
|
|
|
|
|
session_id=test_session.session_id, source=TicketSource.CHAT_AI,
|
|
|
|
|
)
|
|
|
|
|
db.refresh(test_session)
|
|
|
|
|
assert test_session.ticket_id == ticket.display_number
|
|
|
|
|
assert test_session.integration_type == NATIVE_INTEGRATION_TYPE
|
|
|
|
|
assert test_session.ticket_status == TicketStatus.OPEN.value
|
|
|
|
|
assert service.repo.get_by_session(test_session.session_id).id == ticket.id
|
|
|
|
|
# Customer/agent inherited from the session
|
|
|
|
|
assert ticket.customer_id == test_session.customer_id
|
|
|
|
|
assert ticket.agent_id == test_session.agent_id
|
|
|
|
|
|
|
|
|
|
def test_create_enqueues_triage_run(self, service, db, test_organization):
|
|
|
|
|
ticket = make_ticket(service, db, test_organization)
|
|
|
|
|
runs = service.run_repo.list_for_ticket(ticket.id)
|
|
|
|
|
assert len(runs) == 1
|
|
|
|
|
assert runs[0].run_type == "triage"
|
|
|
|
|
assert runs[0].status == "pending"
|
|
|
|
|
|
|
|
|
|
def test_create_without_auto_investigate(self, service, db, test_organization):
|
|
|
|
|
settings = service.settings_repo.get_or_create(test_organization.id)
|
|
|
|
|
settings.auto_investigate_on_create = False
|
|
|
|
|
db.commit()
|
|
|
|
|
ticket = make_ticket(service, db, test_organization)
|
|
|
|
|
assert service.run_repo.list_for_ticket(ticket.id) == []
|
|
|
|
|
|
|
|
|
|
def test_creation_writes_audit_activity(self, service, db, test_organization):
|
|
|
|
|
ticket = make_ticket(service, db, test_organization)
|
|
|
|
|
activities = service.activity_repo.list_for_ticket(ticket.id)
|
|
|
|
|
assert activities[0].activity_type == TicketActivityType.STATUS_CHANGE.value
|
|
|
|
|
assert activities[0].activity_metadata["to"] == TicketStatus.OPEN.value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestOrgIsolation:
|
|
|
|
|
def test_foreign_session_rejected(self, service, db, test_organization, test_session):
|
|
|
|
|
"""A session belonging to another org must not be linkable (it would
|
|
|
|
|
mutate that org's session row and message its customer)."""
|
|
|
|
|
from app.models.organization import Organization
|
|
|
|
|
other_org = Organization(name="Other Org", domain="other.example", timezone="UTC")
|
|
|
|
|
db.add(other_org)
|
|
|
|
|
db.commit()
|
|
|
|
|
with pytest.raises(ValueError, match="Session not found"):
|
|
|
|
|
service.create_ticket(
|
|
|
|
|
organization_id=other_org.id,
|
|
|
|
|
title="Sneaky cross-org ticket",
|
|
|
|
|
session_id=test_session.session_id,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_jira_linked_session_not_clobbered(
|
|
|
|
|
self, service, db, test_organization, test_session
|
|
|
|
|
):
|
|
|
|
|
"""An explicit Jira linkage on the session survives native ticket
|
|
|
|
|
creation — JiraTools reads ticket_id as a Jira issue key."""
|
|
|
|
|
test_session.ticket_id = "SUP-123"
|
|
|
|
|
test_session.integration_type = "JIRA"
|
|
|
|
|
db.commit()
|
|
|
|
|
make_ticket(
|
|
|
|
|
service, db, test_organization, session_id=test_session.session_id
|
|
|
|
|
)
|
|
|
|
|
db.refresh(test_session)
|
|
|
|
|
assert test_session.ticket_id == "SUP-123"
|
|
|
|
|
assert test_session.integration_type == "JIRA"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSearchEscaping:
|
|
|
|
|
def test_literal_percent_does_not_wildcard(self, service, db, test_organization):
|
|
|
|
|
make_ticket(service, db, test_organization, title="CPU at 100% on ingest")
|
|
|
|
|
make_ticket(service, db, test_organization, title="Login broken")
|
|
|
|
|
tickets, total = service.repo.list(test_organization.id, search="100% on")
|
|
|
|
|
assert total == 1
|
|
|
|
|
# A bare '%' must not match everything.
|
|
|
|
|
_t, total_all = service.repo.list(test_organization.id, search="%")
|
|
|
|
|
assert total_all == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTriagePriorityValidation:
|
|
|
|
|
def test_invalid_llm_priority_is_ignored(self, service, db, test_organization):
|
|
|
|
|
from app.models.schemas.investigation import TriageResult
|
|
|
|
|
from app.workers.ticket_investigator import _apply_triage
|
|
|
|
|
ticket = make_ticket(service, db, test_organization)
|
|
|
|
|
result = TriageResult(
|
|
|
|
|
title=ticket.title, priority="P1-Critical", intent="bug_report",
|
|
|
|
|
summary="something broke", confidence=0.9,
|
|
|
|
|
)
|
|
|
|
|
_apply_triage(service, ticket, result)
|
|
|
|
|
assert str(ticket.priority) == "high" # unchanged
|
|
|
|
|
|
|
|
|
|
def test_valid_llm_priority_applies(self, service, db, test_organization):
|
|
|
|
|
from app.models.schemas.investigation import TriageResult
|
|
|
|
|
from app.workers.ticket_investigator import _apply_triage
|
|
|
|
|
ticket = make_ticket(service, db, test_organization)
|
|
|
|
|
result = TriageResult(
|
|
|
|
|
title=ticket.title, priority="Urgent", intent="outage",
|
|
|
|
|
summary="prod down", confidence=0.95,
|
|
|
|
|
)
|
|
|
|
|
_apply_triage(service, ticket, result)
|
|
|
|
|
assert str(ticket.priority) == "urgent"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestCustomerEmailPath:
|
|
|
|
|
def test_create_with_email_finds_or_creates_customer(
|
|
|
|
|
self, service, db, test_organization
|
|
|
|
|
):
|
|
|
|
|
ticket = make_ticket(
|
|
|
|
|
service, db, test_organization,
|
|
|
|
|
customer_email="Jane@Example.com", customer_name="Jane Doe",
|
|
|
|
|
)
|
|
|
|
|
assert ticket.customer is not None
|
|
|
|
|
assert ticket.customer.email == "jane@example.com"
|
|
|
|
|
assert ticket.customer.full_name == "Jane Doe"
|
|
|
|
|
# Same email reuses the record instead of duplicating it.
|
|
|
|
|
second = make_ticket(
|
|
|
|
|
service, db, test_organization, customer_email="jane@example.com"
|
|
|
|
|
)
|
|
|
|
|
assert second.customer_id == ticket.customer_id
|
|
|
|
|
|
|
|
|
|
def test_can_notify_requires_session_or_email(
|
|
|
|
|
self, service, db, test_organization, test_session
|
|
|
|
|
):
|
|
|
|
|
no_contact = make_ticket(service, db, test_organization)
|
|
|
|
|
assert service.can_notify_customer(no_contact) is False
|
|
|
|
|
with_email = make_ticket(
|
|
|
|
|
service, db, test_organization, customer_email="c@example.com"
|
|
|
|
|
)
|
|
|
|
|
assert service.can_notify_customer(with_email) is True
|
|
|
|
|
with_session = make_ticket(
|
|
|
|
|
service, db, test_organization, session_id=test_session.session_id
|
|
|
|
|
)
|
|
|
|
|
assert service.can_notify_customer(with_session) is True
|
|
|
|
|
|
|
|
|
|
def test_set_customer_links_and_records_activity(
|
|
|
|
|
self, service, db, test_organization
|
|
|
|
|
):
|
|
|
|
|
ticket = make_ticket(service, db, test_organization)
|
|
|
|
|
assert ticket.customer_id is None
|
|
|
|
|
service.set_customer(ticket, "Late@Example.com", "Late Add")
|
|
|
|
|
db.commit()
|
|
|
|
|
db.refresh(ticket)
|
|
|
|
|
assert ticket.customer.email == "late@example.com"
|
|
|
|
|
assert service.can_notify_customer(ticket) is True
|
|
|
|
|
last = service.activity_repo.list_for_ticket(ticket.id)[-1]
|
|
|
|
|
assert last.activity_type == TicketActivityType.CUSTOMER_LINKED.value
|
|
|
|
|
# Setting the same customer again is a no-op (no duplicate activity).
|
|
|
|
|
count_before = len(service.activity_repo.list_for_ticket(ticket.id))
|
|
|
|
|
service.set_customer(ticket, "late@example.com")
|
|
|
|
|
assert len(service.activity_repo.list_for_ticket(ticket.id)) == count_before
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_direct_email_fallback_records_activity(
|
|
|
|
|
self, service, db, test_organization
|
|
|
|
|
):
|
|
|
|
|
ticket = make_ticket(
|
|
|
|
|
service, db, test_organization, customer_email="c@example.com"
|
|
|
|
|
)
|
|
|
|
|
with patch(
|
|
|
|
|
"app.services.ticket_email.send_ticket_email", return_value=True
|
|
|
|
|
) as send:
|
|
|
|
|
await service.send_customer_message(ticket, "We are on it")
|
|
|
|
|
send.assert_awaited_once()
|
|
|
|
|
assert send.await_args.kwargs["subject"].startswith(f"[{ticket.display_number}]")
|
|
|
|
|
last = service.activity_repo.list_for_ticket(ticket.id)[-1]
|
|
|
|
|
assert last.activity_type == TicketActivityType.CUSTOMER_NOTIFIED.value
|
|
|
|
|
assert last.activity_metadata["channel"] == "email"
|
|
|
|
|
assert ticket.first_response_at is not None
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_web_session_reply_is_also_emailed(
|
|
|
|
|
self, service, db, test_organization, test_session
|
|
|
|
|
):
|
|
|
|
|
"""A widget (web) reply is emitted over the socket to the browser tab
|
|
|
|
|
only; when the customer has a real inbox it must also be emailed so a
|
|
|
|
|
ticket update reaches them after they've closed the chat."""
|
|
|
|
|
ticket = make_ticket(
|
|
|
|
|
service, db, test_organization, session_id=test_session.session_id
|
|
|
|
|
)
|
|
|
|
|
with patch(
|
|
|
|
|
"app.services.ticket.deliver_to_customer"
|
|
|
|
|
) as deliver, patch(
|
|
|
|
|
"app.services.ticket_email.send_ticket_email", return_value=True
|
|
|
|
|
) as send:
|
|
|
|
|
await service.send_customer_message(ticket, "Update on your ticket")
|
|
|
|
|
deliver.assert_awaited_once()
|
|
|
|
|
send.assert_awaited_once()
|
|
|
|
|
assert send.await_args.args[2] == test_session.customer.email
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_web_session_reply_not_emailed_for_placeholder(
|
|
|
|
|
self, service, db, test_organization, test_session, test_customer
|
|
|
|
|
):
|
|
|
|
|
"""Anonymous widget visitors (…@noemail.com) have no real inbox — the
|
|
|
|
|
socket emit is the only delivery, no email is attempted."""
|
|
|
|
|
test_customer.email = "visitor@noemail.com"
|
|
|
|
|
db.commit()
|
|
|
|
|
ticket = make_ticket(
|
|
|
|
|
service, db, test_organization, session_id=test_session.session_id
|
|
|
|
|
)
|
|
|
|
|
with patch(
|
|
|
|
|
"app.services.ticket.deliver_to_customer"
|
|
|
|
|
), patch(
|
|
|
|
|
"app.services.ticket_email.send_ticket_email", return_value=True
|
|
|
|
|
) as send:
|
|
|
|
|
await service.send_customer_message(ticket, "Update on your ticket")
|
|
|
|
|
send.assert_not_awaited()
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_undelivered_channel_reply_is_marked_on_its_row(
|
|
|
|
|
self, service, db, test_organization, test_session
|
|
|
|
|
):
|
|
|
|
|
"""A WhatsApp reply refused (e.g. window closed) is stored first and
|
|
|
|
|
flagged, so the thread never shows it as sent."""
|
|
|
|
|
test_session.channel = "whatsapp"
|
|
|
|
|
db.commit()
|
|
|
|
|
ticket = make_ticket(service, db, test_organization, session_id=test_session.session_id)
|
|
|
|
|
refused = DeliveryResult(ok=False, reason="window_expired", can_template=True)
|
|
|
|
|
with patch("app.services.ticket.deliver_to_customer", return_value=refused) as deliver:
|
|
|
|
|
await service.send_customer_message(ticket, "Your refund is on its way")
|
|
|
|
|
|
|
|
|
|
row = db.query(ChatHistory).filter_by(session_id=test_session.session_id).one()
|
|
|
|
|
assert deliver.await_args.kwargs["message_id"] == row.id
|
|
|
|
|
assert row.attributes["delivery_status"] == "window_expired"
|
2026-10-29 09:46:21 +08:00
|
|
|
assert ticket.first_response_at is None
|
|
|
|
|
assert TicketActivityType.CUSTOMER_NOTIFIED.value not in [
|
|
|
|
|
activity.activity_type
|
|
|
|
|
for activity in service.activity_repo.list_for_ticket(ticket.id)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_refused_channel_reply_preserves_an_existing_first_response(
|
|
|
|
|
self, service, db, test_organization, test_session
|
|
|
|
|
):
|
|
|
|
|
test_session.channel = "whatsapp"
|
|
|
|
|
db.commit()
|
|
|
|
|
ticket = make_ticket(service, db, test_organization, session_id=test_session.session_id)
|
|
|
|
|
prior_response = datetime(2026, 10, 1, tzinfo=timezone.utc)
|
|
|
|
|
ticket.first_response_at = prior_response
|
|
|
|
|
db.commit()
|
|
|
|
|
db.refresh(ticket)
|
|
|
|
|
prior_response = ticket.first_response_at
|
|
|
|
|
refused = DeliveryResult(ok=False, reason="window_expired", can_template=True)
|
|
|
|
|
|
|
|
|
|
with patch("app.services.ticket.deliver_to_customer", return_value=refused):
|
|
|
|
|
await service.send_customer_message(ticket, "Your refund is on its way")
|
|
|
|
|
|
|
|
|
|
assert ticket.first_response_at == prior_response
|
|
|
|
|
assert TicketActivityType.CUSTOMER_NOTIFIED.value not in [
|
|
|
|
|
activity.activity_type
|
|
|
|
|
for activity in service.activity_repo.list_for_ticket(ticket.id)
|
|
|
|
|
]
|
2026-10-08 22:35:00 +08:00
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_a_send_that_raises_is_marked_undelivered(
|
|
|
|
|
self, service, db, test_organization, test_session
|
|
|
|
|
):
|
|
|
|
|
test_session.channel = "whatsapp"
|
|
|
|
|
db.commit()
|
|
|
|
|
ticket = make_ticket(service, db, test_organization, session_id=test_session.session_id)
|
|
|
|
|
with patch("app.services.ticket.deliver_to_customer", side_effect=RuntimeError("graph down")):
|
|
|
|
|
await service.send_customer_message(ticket, "Your refund is on its way")
|
|
|
|
|
|
|
|
|
|
row = db.query(ChatHistory).filter_by(session_id=test_session.session_id).one()
|
|
|
|
|
assert row.attributes["delivery_status"] == "send_failed"
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_no_contact_is_a_silent_noop(self, service, db, test_organization):
|
|
|
|
|
ticket = make_ticket(service, db, test_organization)
|
|
|
|
|
with patch(
|
|
|
|
|
"app.services.ticket_email.send_ticket_email", return_value=True
|
|
|
|
|
) as send:
|
|
|
|
|
await service.send_customer_message(ticket, "hello?")
|
|
|
|
|
send.assert_not_awaited()
|
|
|
|
|
types = [a.activity_type for a in service.activity_repo.list_for_ticket(ticket.id)]
|
|
|
|
|
assert TicketActivityType.CUSTOMER_NOTIFIED.value not in types
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_failed_send_records_nothing(self, service, db, test_organization):
|
|
|
|
|
ticket = make_ticket(
|
|
|
|
|
service, db, test_organization, customer_email="c@example.com"
|
|
|
|
|
)
|
|
|
|
|
with patch("app.services.ticket_email.send_ticket_email", return_value=False):
|
|
|
|
|
await service.send_customer_message(ticket, "We are on it")
|
|
|
|
|
types = [a.activity_type for a in service.activity_repo.list_for_ticket(ticket.id)]
|
|
|
|
|
assert TicketActivityType.CUSTOMER_NOTIFIED.value not in types
|
|
|
|
|
assert ticket.first_response_at is None
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_repeat_resolve_notifies_customer_once(
|
|
|
|
|
self, service, db, test_organization, test_session
|
|
|
|
|
):
|
|
|
|
|
"""A double-submitted resolve must send the customer exactly one
|
|
|
|
|
resolution message — the second call is a no-op transition and must not
|
|
|
|
|
re-notify."""
|
|
|
|
|
ticket = make_ticket(
|
|
|
|
|
service, db, test_organization, session_id=test_session.session_id
|
|
|
|
|
)
|
|
|
|
|
with patch.object(
|
|
|
|
|
service, "notify_customer_resolved"
|
|
|
|
|
) as notify:
|
|
|
|
|
await service.resolve(ticket, outcome="fixed", customer_message="All set")
|
|
|
|
|
await service.resolve(ticket, outcome="fixed", customer_message="All set")
|
|
|
|
|
notify.assert_awaited_once()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestStatusMachine:
|
|
|
|
|
def test_legal_transition(self, service, db, test_organization):
|
|
|
|
|
ticket = make_ticket(service, db, test_organization)
|
|
|
|
|
service.transition_status(ticket, TicketStatus.IN_PROGRESS)
|
|
|
|
|
assert ticket.status == TicketStatus.IN_PROGRESS
|
|
|
|
|
|
|
|
|
|
def test_illegal_transition_raises(self, service, db, test_organization):
|
|
|
|
|
ticket = make_ticket(service, db, test_organization)
|
|
|
|
|
service.transition_status(ticket, TicketStatus.CLOSED)
|
|
|
|
|
with pytest.raises(ValueError, match="Illegal status transition"):
|
|
|
|
|
service.transition_status(ticket, TicketStatus.IN_PROGRESS)
|
|
|
|
|
|
|
|
|
|
def test_resolved_sets_timestamps_and_actor(self, service, db, test_organization):
|
|
|
|
|
ticket = make_ticket(service, db, test_organization)
|
|
|
|
|
service.transition_status(ticket, TicketStatus.RESOLVED, actor_type=TicketActorType.AI)
|
|
|
|
|
assert ticket.resolved_at is not None
|
|
|
|
|
assert ticket.resolved_by_actor == "ai"
|
|
|
|
|
|
|
|
|
|
def test_reopen_clears_resolution_state(self, service, db, test_organization):
|
|
|
|
|
ticket = make_ticket(service, db, test_organization)
|
|
|
|
|
service.transition_status(ticket, TicketStatus.RESOLVED)
|
|
|
|
|
service.reopen(ticket, reason="Customer says still broken")
|
|
|
|
|
assert ticket.status == TicketStatus.REOPENED
|
|
|
|
|
assert ticket.resolved_at is None
|
|
|
|
|
assert ticket.resolved_by_actor is None
|
|
|
|
|
assert ticket.reopened_count == 1
|
|
|
|
|
|
|
|
|
|
def test_transition_writes_audit_row(self, service, db, test_organization):
|
|
|
|
|
ticket = make_ticket(service, db, test_organization)
|
|
|
|
|
service.transition_status(ticket, TicketStatus.IN_PROGRESS)
|
|
|
|
|
activities = service.activity_repo.list_for_ticket(ticket.id)
|
|
|
|
|
last = activities[-1]
|
|
|
|
|
assert last.activity_metadata == {
|
|
|
|
|
"from": TicketStatus.OPEN.value,
|
|
|
|
|
"to": TicketStatus.IN_PROGRESS.value,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestComments:
|
|
|
|
|
def test_customer_visible_comment_sets_first_response(self, service, db, test_organization):
|
|
|
|
|
ticket = make_ticket(service, db, test_organization)
|
|
|
|
|
assert ticket.first_response_at is None
|
|
|
|
|
service.add_comment(ticket, "Looking into it", is_internal=False)
|
|
|
|
|
assert ticket.first_response_at is not None
|
|
|
|
|
|
|
|
|
|
def test_internal_note_does_not_set_first_response(self, service, db, test_organization):
|
|
|
|
|
ticket = make_ticket(service, db, test_organization)
|
|
|
|
|
service.add_comment(ticket, "internal note", is_internal=True)
|
|
|
|
|
assert ticket.first_response_at is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestAiState:
|
|
|
|
|
def test_active_run_means_investigating(self, service, db, test_organization):
|
|
|
|
|
ticket = make_ticket(service, db, test_organization)
|
|
|
|
|
assert service.ai_state(ticket) == "investigating"
|
|
|
|
|
|
|
|
|
|
def test_no_run_means_human(self, service, db, test_organization):
|
|
|
|
|
settings = service.settings_repo.get_or_create(test_organization.id)
|
|
|
|
|
settings.auto_investigate_on_create = False
|
|
|
|
|
db.commit()
|
|
|
|
|
ticket = make_ticket(service, db, test_organization)
|
|
|
|
|
assert service.ai_state(ticket) == "human"
|
|
|
|
|
|
|
|
|
|
def test_ai_resolution_means_resolved(self, service, db, test_organization):
|
|
|
|
|
settings = service.settings_repo.get_or_create(test_organization.id)
|
|
|
|
|
settings.auto_investigate_on_create = False
|
|
|
|
|
db.commit()
|
|
|
|
|
ticket = make_ticket(service, db, test_organization)
|
|
|
|
|
service.transition_status(ticket, TicketStatus.RESOLVED, actor_type=TicketActorType.AI)
|
|
|
|
|
assert service.ai_state(ticket) == "resolved"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRunGuards:
|
|
|
|
|
def test_only_one_active_run_per_ticket(self, service, db, test_organization):
|
|
|
|
|
ticket = make_ticket(service, db, test_organization)
|
|
|
|
|
# Auto-created triage run is still pending — a second enqueue is refused.
|
|
|
|
|
assert service.enqueue_run(ticket) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestStats:
|
|
|
|
|
def test_stats_counts(self, service, db, test_organization):
|
|
|
|
|
make_ticket(service, db, test_organization)
|
|
|
|
|
ticket = make_ticket(service, db, test_organization)
|
|
|
|
|
service.transition_status(ticket, TicketStatus.AWAITING_APPROVAL)
|
|
|
|
|
db.commit()
|
|
|
|
|
stats = service.stats(test_organization.id)
|
|
|
|
|
assert stats["open"] == 2
|
|
|
|
|
assert stats["awaiting_approval"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTemplates:
|
|
|
|
|
def test_placeholders_render(self):
|
|
|
|
|
rendered = render_customer_template("Hi [customer] — [ticket] is open.", "Ada", "TKT-7")
|
|
|
|
|
assert rendered == "Hi Ada — TKT-7 is open."
|
|
|
|
|
|
|
|
|
|
def test_missing_customer_name_falls_back(self):
|
|
|
|
|
rendered = render_customer_template("Hi [customer]!", None, "TKT-7")
|
|
|
|
|
assert rendered == "Hi there!"
|