Files
dify/api/tests/unit_tests/controllers/console/app/test_conversation.py
T

380 lines
14 KiB
Python

from __future__ import annotations
import datetime
from decimal import Decimal
from inspect import unwrap
from unittest.mock import MagicMock, patch
import pytest
import sqlalchemy as sa
from flask import Flask
from sqlalchemy.orm import Session
from werkzeug.exceptions import BadRequest, NotFound
from controllers.console.app import conversation as conversation_module
from core.app.entities.app_invoke_entities import InvokeFrom
from graphon.enums import WorkflowExecutionStatus
from models import Account, App, EndUser, Message, MessageAnnotation, MessageFeedback
from models.enums import (
ConversationFromSource,
CreatorUserRole,
EndUserType,
FeedbackFromSource,
FeedbackRating,
WorkflowRunTriggeredFrom,
)
from models.model import AppMode, Conversation
from models.workflow import WorkflowRun, WorkflowType
from services.errors.conversation import ConversationNotExistsError
from tests.helpers.unit.model_factories import make_app, make_conversation
def _make_account() -> Account:
account = Account(name="Account", email="account@example.com", timezone="UTC")
account.id = "u1"
return account
def _app(*, mode: AppMode = AppMode.CHAT) -> App:
return make_app(name="Conversation app", mode=mode)
def _conversation(*, conversation_id: str = "c1", app_id: str = "app-1") -> Conversation:
return make_conversation(
conversation_id=conversation_id,
app_id=app_id,
inputs={},
introduction="",
system_instruction="",
system_instruction_tokens=0,
status="normal",
invoke_from=InvokeFrom.EXPLORE,
from_source=ConversationFromSource.CONSOLE,
from_account_id="u1",
)
def test_completion_conversation_list_returns_paginated_result(
app: Flask, monkeypatch: pytest.MonkeyPatch, unbound_session: Session
) -> None:
api = conversation_module.CompletionConversationApi()
method = unwrap(api.get)
account = _make_account()
monkeypatch.setattr(conversation_module, "parse_time_range", lambda *_args, **_kwargs: (None, None))
paginate_result = MagicMock()
paginate_result.page = 1
paginate_result.per_page = 20
paginate_result.total = 0
paginate_result.has_next = False
paginate_result.items = []
monkeypatch.setattr(conversation_module, "paginate_query", lambda *_args, **_kwargs: paginate_result)
with app.test_request_context("/console/api/apps/app-1/completion-conversations", method="GET"):
response = method(
api,
conversation_module.CompletionConversationQuery(),
unbound_session,
account,
app_model=_app(mode=AppMode.COMPLETION),
)
assert response == {"page": 1, "limit": 20, "total": 0, "has_more": False, "data": []}
def test_completion_conversation_list_invalid_time_range(
app: Flask, monkeypatch: pytest.MonkeyPatch, unbound_session: Session
) -> None:
api = conversation_module.CompletionConversationApi()
method = unwrap(api.get)
account = _make_account()
monkeypatch.setattr(
conversation_module,
"parse_time_range",
lambda *_args, **_kwargs: (_ for _ in ()).throw(ValueError("bad range")),
)
with app.test_request_context(
"/console/api/apps/app-1/completion-conversations", method="GET", query_string={"start": "bad"}
):
with pytest.raises(BadRequest):
method(
api,
conversation_module.CompletionConversationQuery(),
unbound_session,
account,
app_model=_app(mode=AppMode.COMPLETION),
)
def test_chat_conversation_list_advanced_chat_calls_paginate(
app: Flask, monkeypatch: pytest.MonkeyPatch, unbound_session: Session
) -> None:
api = conversation_module.ChatConversationApi()
method = unwrap(api.get)
account = _make_account()
monkeypatch.setattr(conversation_module, "parse_time_range", lambda *_args, **_kwargs: (None, None))
paginate_result = MagicMock()
paginate_result.page = 1
paginate_result.per_page = 20
paginate_result.total = 0
paginate_result.has_next = False
paginate_result.items = []
monkeypatch.setattr(conversation_module, "paginate_query", lambda *_args, **_kwargs: paginate_result)
with app.test_request_context("/console/api/apps/app-1/chat-conversations", method="GET"):
response = method(
api,
conversation_module.ChatConversationQuery(),
unbound_session,
account,
app_model=_app(mode=AppMode.ADVANCED_CHAT),
)
assert response == {"page": 1, "limit": 20, "total": 0, "has_more": False, "data": []}
def test_get_conversation_updates_read_at(sqlite_session: Session) -> None:
conversation = _conversation()
sqlite_session.add(conversation)
sqlite_session.flush()
session = sqlite_session
result = conversation_module._get_conversation(session, _make_account(), _app(), "c1")
assert result is conversation
assert conversation.read_at is not None
assert conversation.read_account_id == "u1"
def test_get_conversation_missing_raises_not_found(sqlite_session: Session) -> None:
session = sqlite_session
with pytest.raises(NotFound):
conversation_module._get_conversation(session, _make_account(), _app(), "missing")
def test_conversation_response_source_uses_caller_session(sqlite_session: Session) -> None:
account = _make_account()
end_user = EndUser(
id="end-user-1",
tenant_id="tenant-1",
app_id="app-1",
type=EndUserType.SERVICE_API,
external_user_id="external-user-1",
name="End user",
session_id="end-user-session",
)
conversation = _conversation()
conversation.mode = AppMode.ADVANCED_CHAT
conversation.override_model_configs = "{}"
conversation.summary = "summary"
conversation.inputs = {"topic": "support"}
conversation.from_end_user_id = end_user.id
conversation.from_account_id = account.id
message = Message(
id="message-1",
app_id=conversation.app_id,
model_provider=None,
model_id=None,
override_model_configs=None,
conversation_id=conversation.id,
_inputs={},
query="first question",
message={},
message_tokens=1,
message_unit_price=Decimal(0),
message_price_unit=Decimal("0.001"),
answer="answer",
answer_tokens=1,
answer_unit_price=Decimal(0),
answer_price_unit=Decimal("0.001"),
parent_message_id=None,
provider_response_latency=0,
total_price=Decimal(0),
currency="USD",
error=None,
message_metadata=None,
invoke_from=InvokeFrom.EXPLORE,
from_source=ConversationFromSource.CONSOLE,
from_end_user_id=end_user.id,
from_account_id=account.id,
workflow_run_id="run-1",
app_mode=AppMode.ADVANCED_CHAT,
)
annotation = MessageAnnotation(
app_id=conversation.app_id,
question="question",
content="annotation",
account_id=account.id,
conversation_id=conversation.id,
message_id=message.id,
)
feedbacks = [
MessageFeedback(
app_id=conversation.app_id,
conversation_id=conversation.id,
message_id=message.id,
rating=rating,
from_source=source,
from_account_id=account.id,
)
for source, rating in (
(FeedbackFromSource.USER, FeedbackRating.LIKE),
(FeedbackFromSource.USER, FeedbackRating.DISLIKE),
(FeedbackFromSource.ADMIN, FeedbackRating.LIKE),
)
]
workflow_run = WorkflowRun(
id="run-1",
tenant_id="tenant-1",
app_id=conversation.app_id,
workflow_id="workflow-1",
type=WorkflowType.WORKFLOW,
triggered_from=WorkflowRunTriggeredFrom.DEBUGGING,
version="1",
graph="{}",
inputs="{}",
status=WorkflowExecutionStatus.SUCCEEDED,
outputs="{}",
error=None,
elapsed_time=1,
total_tokens=1,
total_steps=1,
created_by_role=CreatorUserRole.ACCOUNT,
created_by=account.id,
finished_at=None,
exceptions_count=0,
)
sqlite_session.add_all([account, end_user, conversation, message, annotation, workflow_run, *feedbacks])
sqlite_session.commit()
source = conversation_module.ConversationResponseSource(conversation, session=sqlite_session)
assert source.inputs == {"topic": "support"}
assert source.model_config == {"model_id": None, "provider": None}
assert source.summary_or_query == "summary"
assert source.annotated is True
annotation_source = source.annotation
assert annotation_source is not None
assert annotation_source.account is account
assert source.message_count == 1
assert source.user_feedback_stats == {"like": 1, "dislike": 1}
assert source.admin_feedback_stats == {"like": 1, "dislike": 0}
assert source.status_count == {"success": 1, "failed": 0, "partial_success": 0, "paused": 0}
assert source.first_message is not None
assert source.from_end_user_session_id == "end-user-session"
assert source.from_account_name == "Account"
def test_completion_conversation_delete_maps_not_found(
monkeypatch: pytest.MonkeyPatch, unbound_session: Session
) -> None:
api = conversation_module.CompletionConversationDetailApi()
method = unwrap(api.delete)
monkeypatch.setattr(
conversation_module.ConversationService,
"delete",
lambda *_args, **_kwargs: (_ for _ in ()).throw(ConversationNotExistsError()),
)
session = unbound_session
with pytest.raises(NotFound):
method(api, session, _make_account(), app_model=_app(), conversation_id="c1")
# ── Regression tests for the < vs <= end-time boundary bug ──────────────────
def _captured_where_clauses(
monkeypatch: pytest.MonkeyPatch,
app: Flask,
endpoint_class,
query_instance,
app_mode,
) -> list[str]:
"""
Run the endpoint's GET handler with a fixed end_datetime and capture the
SQLAlchemy WHERE clause strings that were applied so we can assert on the
comparison operator used for the end-time boundary.
``query_instance`` is passed directly to the unwrapped handler, so callers
can pre-configure it (e.g. ``ChatConversationQuery(sort_by="created_at")``).
"""
# 2024-01-15 14:30:00 UTC — parse_time_range returns second=0; the handler
# then calls .replace(second=59) giving 14:30:59.
boundary_dt = datetime.datetime(2024, 1, 15, 14, 30, 0)
monkeypatch.setattr(
conversation_module,
"parse_time_range",
lambda *_args, **_kwargs: (None, boundary_dt),
)
captured: list[str] = []
original_where = sa.sql.selectable.Select.where
def spy_where(self, *criteria, **kwargs):
for c in criteria:
captured.append(str(c.compile(compile_kwargs={"literal_binds": True})))
return original_where(self, *criteria, **kwargs)
paginate_result = MagicMock()
paginate_result.page = 1
paginate_result.per_page = 20
paginate_result.total = 0
paginate_result.has_next = False
paginate_result.items = []
monkeypatch.setattr(conversation_module, "paginate_query", lambda *_args, **_kwargs: paginate_result)
api = endpoint_class()
method = unwrap(api.get)
account = _make_account()
with Session() as session, app.test_request_context("/", method="GET"):
with patch.object(sa.sql.selectable.Select, "where", spy_where):
method(
api,
query_instance,
session,
account,
app_model=_app(mode=app_mode),
)
return captured
def test_completion_conversation_end_time_filter_is_inclusive(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
"""CompletionConversationApi must use <= (not <) for the end-time boundary.
Regression test for the bug where `created_at < end_datetime_utc` was used
instead of `created_at <= end_datetime_utc`, silently dropping conversations
created at exactly the boundary second (HH:MM:59).
"""
clauses = _captured_where_clauses(
monkeypatch,
app,
conversation_module.CompletionConversationApi,
conversation_module.CompletionConversationQuery(),
AppMode.COMPLETION,
)
# Filter by the boundary timestamp value — field name not assumed.
end_clauses = [c for c in clauses if "14:30:59" in c]
assert end_clauses, "No end-time WHERE clause found for CompletionConversationApi"
for clause in end_clauses:
assert "<=" in clause, f"CompletionConversationApi end-time filter must use <= (inclusive), got: {clause!r}"
assert clause.count("<") == 1, f"Expected exactly one <= operator (no stray < without =), got: {clause!r}"
def test_chat_conversation_end_time_filter_is_inclusive(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
"""ChatConversationApi must use <= for the end-time boundary (control test).
ChatConversationQuery defaults to sort_by='-updated_at', which routes the
end-time filter through the updated_at branch. We explicitly request
sort_by='created_at' to exercise the created_at branch and confirm it also
uses <=, mirroring what CompletionConversationApi does after the fix.
"""
clauses = _captured_where_clauses(
monkeypatch,
app,
conversation_module.ChatConversationApi,
# Use created_at sort so the WHERE clause hits created_at, not updated_at.
conversation_module.ChatConversationQuery(sort_by="created_at"),
AppMode.ADVANCED_CHAT,
)
# Filter by the boundary timestamp value — field name not assumed.
end_clauses = [c for c in clauses if "14:30:59" in c]
assert end_clauses, "No end-time WHERE clause found for ChatConversationApi"
for clause in end_clauses:
assert "<=" in clause, f"ChatConversationApi end-time filter must use <= (inclusive), got: {clause!r}"