From c47dc8f18b0dc7f3878a3e6905b3f752d862ab67 Mon Sep 17 00:00:00 2001 From: fatelei Date: Mon, 8 Dec 2025 17:52:29 +0800 Subject: [PATCH 1/3] feat: add api to retrieving logs from text generation applications and chat applications --- api/controllers/console/__init__.py | 4 + api/controllers/console/app/chat_app_log.py | 80 ++++ .../console/app/completion_app_log.py | 81 ++++ api/fields/chat_app_log_fields.py | 86 ++++ api/fields/completion_app_log_fields.py | 71 +++ api/services/chat_app_log_service.py | 120 ++++++ api/services/completion_app_log_service.py | 91 ++++ api/services/message_app_log_service.py | 184 ++++++++ .../console/app/test_chat_app_log_api.py | 329 ++++++++++++++ .../app/test_completion_app_log_api.py | 232 ++++++++++ .../console/app/test_chat_app_log.py | 408 ++++++++++++++++++ .../console/app/test_completion_app_log.py | 236 ++++++++++ .../services/test_chat_app_log_service.py | 302 +++++++++++++ .../test_completion_app_log_service.py | 219 ++++++++++ 14 files changed, 2443 insertions(+) create mode 100644 api/controllers/console/app/chat_app_log.py create mode 100644 api/controllers/console/app/completion_app_log.py create mode 100644 api/fields/chat_app_log_fields.py create mode 100644 api/fields/completion_app_log_fields.py create mode 100644 api/services/chat_app_log_service.py create mode 100644 api/services/completion_app_log_service.py create mode 100644 api/services/message_app_log_service.py create mode 100644 api/tests/integration_tests/controllers/console/app/test_chat_app_log_api.py create mode 100644 api/tests/integration_tests/controllers/console/app/test_completion_app_log_api.py create mode 100644 api/tests/unit_tests/controllers/console/app/test_chat_app_log.py create mode 100644 api/tests/unit_tests/controllers/console/app/test_completion_app_log.py create mode 100644 api/tests/unit_tests/services/test_chat_app_log_service.py create mode 100644 api/tests/unit_tests/services/test_completion_app_log_service.py diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index ad878fc266..34ac0ba2d2 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -51,7 +51,9 @@ from .app import ( annotation, app, audio, + chat_app_log, completion, + completion_app_log, conversation, conversation_variables, generator, @@ -147,7 +149,9 @@ __all__ = [ "audio", "billing", "bp", + "chat_app_log", "completion", + "completion_app_log", "compliance", "console_ns", "conversation", diff --git a/api/controllers/console/app/chat_app_log.py b/api/controllers/console/app/chat_app_log.py new file mode 100644 index 0000000000..8fec7a5b01 --- /dev/null +++ b/api/controllers/console/app/chat_app_log.py @@ -0,0 +1,80 @@ +from datetime import datetime + +from dateutil.parser import isoparse +from flask import request +from flask_restx import Resource, marshal_with +from pydantic import BaseModel, Field, field_validator +from sqlalchemy.orm import Session + +from controllers.console import console_ns +from controllers.console.app.wraps import get_app_model +from controllers.console.wraps import account_initialization_required, setup_required +from extensions.ext_database import db +from fields.chat_app_log_fields import build_chat_app_log_pagination_model +from libs.login import login_required +from models import App +from models.model import AppMode +from services.chat_app_log_service import ChatAppLogService + +DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" + + +class ChatAppLogQuery(BaseModel): + status: str | None = Field(default=None, description="Message status filter") + created_at__before: datetime | None = Field(default=None, description="Filter logs created before this timestamp") + created_at__after: datetime | None = Field(default=None, description="Filter logs created after this timestamp") + created_by_end_user_session_id: str | None = Field(default=None, description="Filter by end user session ID") + created_by_account: str | None = Field(default=None, description="Filter by account") + page: int = Field(default=1, ge=1, le=99999, description="Page number (1-99999)") + limit: int = Field(default=20, ge=1, le=100, description="Number of items per page (1-100)") + + @field_validator("created_at__before", "created_at__after", mode="before") + @classmethod + def parse_datetime(cls, value: str | None) -> datetime | None: + if value in (None, ""): + return None + return isoparse(value) # type: ignore + + +console_ns.schema_model( + ChatAppLogQuery.__name__, ChatAppLogQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) +) + +# Register model for flask_restx to avoid dict type issues in Swagger +chat_app_log_pagination_model = build_chat_app_log_pagination_model(console_ns) + + +@console_ns.route("/apps//chat-app-logs") +class ChatAppLogApi(Resource): + @console_ns.doc("get_chat_app_logs") + @console_ns.doc(description="Get chat application execution logs with token consumption") + @console_ns.doc(params={"app_id": "Application ID"}) + @console_ns.expect(console_ns.models[ChatAppLogQuery.__name__]) + @console_ns.response(200, "Chat app logs retrieved successfully", chat_app_log_pagination_model) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT]) + @marshal_with(chat_app_log_pagination_model) + def get(self, app_model: App): + """ + Get chat app logs with token consumption information + """ + args = ChatAppLogQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + + # get paginate chat app logs + chat_app_log_service = ChatAppLogService() + with Session(db.engine) as session: + chat_app_log_pagination = chat_app_log_service.get_paginate_chat_app_logs( + session=session, + app_model=app_model, + status=args.status, + created_at_before=args.created_at__before, + created_at_after=args.created_at__after, + page=args.page, + limit=args.limit, + created_by_end_user_session_id=args.created_by_end_user_session_id, + created_by_account=args.created_by_account, + ) + + return chat_app_log_pagination diff --git a/api/controllers/console/app/completion_app_log.py b/api/controllers/console/app/completion_app_log.py new file mode 100644 index 0000000000..9d38b3bbd0 --- /dev/null +++ b/api/controllers/console/app/completion_app_log.py @@ -0,0 +1,81 @@ +from datetime import datetime + +from dateutil.parser import isoparse +from flask import request +from flask_restx import Resource, marshal_with +from pydantic import BaseModel, Field, field_validator +from sqlalchemy.orm import Session + +from controllers.console import console_ns +from controllers.console.app.wraps import get_app_model +from controllers.console.wraps import account_initialization_required, setup_required +from extensions.ext_database import db +from fields.completion_app_log_fields import build_completion_app_log_pagination_model +from libs.login import login_required +from models import App +from models.model import AppMode +from services.completion_app_log_service import CompletionAppLogService + +DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" + + +class CompletionAppLogQuery(BaseModel): + status: str | None = Field(default=None, description="Message status filter") + created_at__before: datetime | None = Field(default=None, description="Filter logs created before this timestamp") + created_at__after: datetime | None = Field(default=None, description="Filter logs created after this timestamp") + created_by_end_user_session_id: str | None = Field(default=None, description="Filter by end user ID") + created_by_account: str | None = Field(default=None, description="Filter by account") + page: int = Field(default=1, ge=1, le=99999, description="Page number (1-99999)") + limit: int = Field(default=20, ge=1, le=100, description="Number of items per page (1-100)") + + @field_validator("created_at__before", "created_at__after", mode="before") + @classmethod + def parse_datetime(cls, value: str | None) -> datetime | None: + if value in (None, ""): + return None + return isoparse(value) # type: ignore + + +console_ns.schema_model( + CompletionAppLogQuery.__name__, + CompletionAppLogQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), +) + +# Register model for flask_restx to avoid dict type issues in Swagger +completion_app_log_pagination_model = build_completion_app_log_pagination_model(console_ns) + + +@console_ns.route("/apps//completion-app-logs") +class CompletionAppLogApi(Resource): + @console_ns.doc("get_completion_app_logs") + @console_ns.doc(description="Get completion application execution logs with token consumption") + @console_ns.doc(params={"app_id": "Application ID"}) + @console_ns.expect(console_ns.models[CompletionAppLogQuery.__name__]) + @console_ns.response(200, "Completion app logs retrieved successfully", completion_app_log_pagination_model) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.COMPLETION]) + @marshal_with(completion_app_log_pagination_model) + def get(self, app_model: App): + """ + Get completion app logs with token consumption information + """ + args = CompletionAppLogQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + + # get paginate completion app logs + completion_app_log_service = CompletionAppLogService() + with Session(db.engine) as session: + completion_app_log_pagination = completion_app_log_service.get_paginate_completion_app_logs( + session=session, + app_model=app_model, + status=args.status, + created_at_before=args.created_at__before, + created_at_after=args.created_at__after, + page=args.page, + limit=args.limit, + created_by_end_user_session_id=args.created_by_end_user_session_id, + created_by_account=args.created_by_account, + ) + + return completion_app_log_pagination diff --git a/api/fields/chat_app_log_fields.py b/api/fields/chat_app_log_fields.py new file mode 100644 index 0000000000..d977bd41c2 --- /dev/null +++ b/api/fields/chat_app_log_fields.py @@ -0,0 +1,86 @@ +from flask_restx import Api, Namespace, fields + +from fields.end_user_fields import build_simple_end_user_model, simple_end_user_fields +from fields.member_fields import build_simple_account_model, simple_account_fields +from libs.helper import TimestampField + +chat_conversation_fields = { + "id": fields.String, + "name": fields.String, + "status": fields.String, +} + +chat_message_fields = { + "id": fields.String, + "conversation_id": fields.String, + "query": fields.String, + "answer": fields.String, + "status": fields.String, + "message_tokens": fields.Integer, + "total_tokens": fields.Integer, + "created_at": TimestampField, + "error": fields.String, + "provider_response_latency": fields.Float, + "from_source": fields.String, + "from_end_user_id": fields.String, + "from_account_id": fields.String, +} + +chat_app_log_partial_fields = { + "id": fields.String, + "conversation": fields.Nested(chat_conversation_fields, attribute="conversation", allow_null=True), + "message": fields.Nested(chat_message_fields, attribute="message", allow_null=False), + "created_from": fields.String, + "created_by_role": fields.String, + "created_by_account": fields.Nested(simple_account_fields, attribute="created_by_account", allow_null=True), + "created_by_end_user": fields.Nested(simple_end_user_fields, attribute="created_by_end_user", allow_null=True), + "created_at": TimestampField, +} + + +def build_chat_conversation_model(api_or_ns: Api | Namespace): + """Build the chat conversation model for the API or Namespace.""" + return api_or_ns.model("ChatConversation", chat_conversation_fields) + + +def build_chat_message_model(api_or_ns: Api | Namespace): + """Build the chat message model for the API or Namespace.""" + return api_or_ns.model("ChatMessage", chat_message_fields) + + +def build_chat_app_log_partial_model(api_or_ns: Api | Namespace): + """Build the chat app log partial model for the API or Namespace.""" + simple_account_model = build_simple_account_model(api_or_ns) + simple_end_user_model = build_simple_end_user_model(api_or_ns) + chat_conversation_model = build_chat_conversation_model(api_or_ns) + chat_message_model = build_chat_message_model(api_or_ns) + + copied_fields = chat_app_log_partial_fields.copy() + copied_fields["conversation"] = fields.Nested(chat_conversation_model, attribute="conversation", allow_null=True) + copied_fields["message"] = fields.Nested(chat_message_model, attribute="message", allow_null=False) + copied_fields["created_by_account"] = fields.Nested( + simple_account_model, attribute="created_by_account", allow_null=True + ) + copied_fields["created_by_end_user"] = fields.Nested( + simple_end_user_model, attribute="created_by_end_user", allow_null=True + ) + return api_or_ns.model("ChatAppLogPartial", copied_fields) + + +chat_app_log_pagination_fields = { + "page": fields.Integer, + "limit": fields.Integer, + "total": fields.Integer, + "has_more": fields.Boolean, + "data": fields.List(fields.Nested(chat_app_log_partial_fields)), +} + + +def build_chat_app_log_pagination_model(api_or_ns: Api | Namespace): + """Build the chat app log pagination model for the API or Namespace.""" + # Build the nested partial model first + chat_app_log_partial_model = build_chat_app_log_partial_model(api_or_ns) + + copied_fields = chat_app_log_pagination_fields.copy() + copied_fields["data"] = fields.List(fields.Nested(chat_app_log_partial_model)) + return api_or_ns.model("ChatAppLogPagination", copied_fields) diff --git a/api/fields/completion_app_log_fields.py b/api/fields/completion_app_log_fields.py new file mode 100644 index 0000000000..b0f4f6689d --- /dev/null +++ b/api/fields/completion_app_log_fields.py @@ -0,0 +1,71 @@ +from flask_restx import Api, Namespace, fields + +from fields.end_user_fields import build_simple_end_user_model, simple_end_user_fields +from fields.member_fields import build_simple_account_model, simple_account_fields +from libs.helper import TimestampField + +completion_message_fields = { + "id": fields.String, + "query": fields.String, + "answer": fields.String, + "status": fields.String, + "message_tokens": fields.Integer, + "total_tokens": fields.Integer, + "created_at": TimestampField, + "error": fields.String, + "provider_response_latency": fields.Float, + "from_source": fields.String, + "from_end_user_id": fields.String, + "from_account_id": fields.String, +} + +completion_app_log_partial_fields = { + "id": fields.String, + "message": fields.Nested(completion_message_fields, attribute="message", allow_null=False), + "created_from": fields.String, + "created_by_role": fields.String, + "created_by_account": fields.Nested(simple_account_fields, attribute="created_by_account", allow_null=True), + "created_by_end_user": fields.Nested(simple_end_user_fields, attribute="created_by_end_user", allow_null=True), + "created_at": TimestampField, +} + + +def build_completion_message_model(api_or_ns: Api | Namespace): + """Build the completion message model for the API or Namespace.""" + return api_or_ns.model("CompletionMessage", completion_message_fields) + + +def build_completion_app_log_partial_model(api_or_ns: Api | Namespace): + """Build the completion app log partial model for the API or Namespace.""" + simple_account_model = build_simple_account_model(api_or_ns) + simple_end_user_model = build_simple_end_user_model(api_or_ns) + completion_message_model = build_completion_message_model(api_or_ns) + + copied_fields = completion_app_log_partial_fields.copy() + copied_fields["message"] = fields.Nested(completion_message_model, attribute="message", allow_null=False) + copied_fields["created_by_account"] = fields.Nested( + simple_account_model, attribute="created_by_account", allow_null=True + ) + copied_fields["created_by_end_user"] = fields.Nested( + simple_end_user_model, attribute="created_by_end_user", allow_null=True + ) + return api_or_ns.model("CompletionAppLogPartial", copied_fields) + + +completion_app_log_pagination_fields = { + "page": fields.Integer, + "limit": fields.Integer, + "total": fields.Integer, + "has_more": fields.Boolean, + "data": fields.List(fields.Nested(completion_app_log_partial_fields)), +} + + +def build_completion_app_log_pagination_model(api_or_ns: Api | Namespace): + """Build the completion app log pagination model for the API or Namespace.""" + # Build the nested partial model first + completion_app_log_partial_model = build_completion_app_log_partial_model(api_or_ns) + + copied_fields = completion_app_log_pagination_fields.copy() + copied_fields["data"] = fields.List(fields.Nested(completion_app_log_partial_model)) + return api_or_ns.model("CompletionAppLogPagination", copied_fields) diff --git a/api/services/chat_app_log_service.py b/api/services/chat_app_log_service.py new file mode 100644 index 0000000000..300e2beab3 --- /dev/null +++ b/api/services/chat_app_log_service.py @@ -0,0 +1,120 @@ +from datetime import datetime + +from sqlalchemy import and_, func, or_, select +from sqlalchemy.orm import Session + +from models import Account, App, Conversation, EndUser, Message +from models.enums import CreatorUserRole +from models.model import AppMode +from services.message_app_log_service import MessageAppLogServiceBase + + +class ChatAppLogService(MessageAppLogServiceBase): + def get_paginate_chat_app_logs( + self, + *, + session: Session, + app_model: App, + status: str | None = None, + created_at_before: datetime | None = None, + created_at_after: datetime | None = None, + page: int = 1, + limit: int = 20, + created_by_end_user_session_id: str | None = None, + created_by_account: str | None = None, + ) -> dict: + """ + Get paginated chat app logs with token consumption information. + """ + return self.get_paginate_app_logs( + session=session, + app_model=app_model, + status=status, + created_at_before=created_at_before, + created_at_after=created_at_after, + page=page, + limit=limit, + created_by_end_user_session_id=created_by_end_user_session_id, + created_by_account=created_by_account, + ) + + def get_app_mode_filter(self): + """Return the filter condition for chat app modes.""" + return or_( + Message.app_mode == AppMode.CHAT.value, + Message.app_mode == AppMode.AGENT_CHAT.value, + Message.app_mode == AppMode.ADVANCED_CHAT.value, + ) + + def build_log_data(self, session, message: Message, conversation=None): + """Build log data for chat app.""" + # For chat apps, we use from_account_id/from_end_user_id instead of created_by_* + account_obj = None + end_user_obj = None + created_from = "api" + created_by_role = None + + if message.from_account_id: + account_obj = session.get(Account, message.from_account_id) + created_from = "web_app" + created_by_role = CreatorUserRole.ACCOUNT.value + elif message.from_end_user_id: + end_user_obj = session.get(EndUser, message.from_end_user_id) + created_from = "service_api" + created_by_role = CreatorUserRole.END_USER.value + + return { + "id": str(message.id), + "conversation": { + "id": str(conversation.id) if conversation else None, + "name": conversation.name if conversation else None, + "status": conversation.status if conversation else None, + }, + "message": { + "id": str(message.id), + "conversation_id": str(message.conversation_id), + "query": message.query, + "answer": message.answer, + "status": message.status, + "message_tokens": message.message_tokens, + "total_tokens": message.total_tokens, + "created_at": message.created_at, + "error": message.error, + "provider_response_latency": message.provider_response_latency, + "from_source": message.from_source, + "from_end_user_id": message.from_end_user_id, + "from_account_id": message.from_account_id, + }, + "created_from": created_from, + "created_by_role": created_by_role, + "created_by_account": account_obj, + "created_by_end_user": end_user_obj, + "created_at": message.created_at, + } + + def _build_base_query(self, app_model: App): + """Build the base query for chat apps.""" + return ( + select(Message) + .join(Conversation, Message.conversation_id == Conversation.id) + .where( + and_( + Message.app_id == app_model.id, + self.get_app_mode_filter(), + ) + ) + .order_by(Message.created_at.desc()) + ) + + def _build_total_count_query(self, app_model: App): + """Build the total count query for chat apps.""" + return ( + select(func.count(Message.id)) + .join(Conversation, Message.conversation_id == Conversation.id) + .where( + and_( + Message.app_id == app_model.id, + self.get_app_mode_filter(), + ) + ) + ) diff --git a/api/services/completion_app_log_service.py b/api/services/completion_app_log_service.py new file mode 100644 index 0000000000..b69f3ca804 --- /dev/null +++ b/api/services/completion_app_log_service.py @@ -0,0 +1,91 @@ +from datetime import datetime + +from sqlalchemy import and_, func, select +from sqlalchemy.orm import Session + +from models import App, Message +from models.model import AppMode +from services.message_app_log_service import MessageAppLogServiceBase + + +class CompletionAppLogService(MessageAppLogServiceBase): + def get_paginate_completion_app_logs( + self, + *, + session: Session, + app_model: App, + status: str | None = None, + created_at_before: datetime | None = None, + created_at_after: datetime | None = None, + page: int = 1, + limit: int = 20, + created_by_end_user_session_id: str | None = None, + created_by_account: str | None = None, + ) -> dict: + """ + Get paginated completion app logs with token consumption information. + """ + return self.get_paginate_app_logs( + session=session, + app_model=app_model, + status=status, + created_at_before=created_at_before, + created_at_after=created_at_after, + page=page, + limit=limit, + created_by_end_user_session_id=created_by_end_user_session_id, + created_by_account=created_by_account, + ) + + def get_app_mode_filter(self): + """Return the filter condition for completion app mode.""" + return Message.app_mode == AppMode.COMPLETION.value + + def build_log_data(self, session, message, conversation=None): + """Build log data for completion app.""" + account_obj, end_user_obj, created_from, created_by_role = self._get_creator_info(session, message) + + return { + "id": str(message.id), + "message": { + "id": str(message.id), + "query": message.query, + "answer": message.answer, + "status": message.status, + "message_tokens": message.message_tokens, + "total_tokens": message.total_tokens, + "created_at": message.created_at, + "error": message.error, + "provider_response_latency": message.provider_response_latency, + "from_source": message.from_source, + "from_end_user_id": message.from_end_user_id, + "from_account_id": message.from_account_id, + }, + "created_from": created_from, + "created_by_role": created_by_role, + "created_by_account": account_obj, + "created_by_end_user": end_user_obj, + "created_at": message.created_at, + } + + def _build_base_query(self, app_model: App): + """Build the base query for completion apps.""" + return ( + select(Message) + .where( + and_( + Message.app_id == app_model.id, + self.get_app_mode_filter(), + ) + ) + .order_by(Message.created_at.desc()) + ) + + def _build_total_count_query(self, app_model: App): + """Build the total count query for completion apps.""" + return select(func.count(Message.id)).where( + and_( + Message.app_id == app_model.id, + self.get_app_mode_filter(), + ) + ) diff --git a/api/services/message_app_log_service.py b/api/services/message_app_log_service.py new file mode 100644 index 0000000000..ff07bbef91 --- /dev/null +++ b/api/services/message_app_log_service.py @@ -0,0 +1,184 @@ +from abc import ABC, abstractmethod +from datetime import datetime +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from models import Account, App, Conversation, EndUser, Message +from models.enums import CreatorUserRole + + +class MessageAppLogServiceBase(ABC): + """Base service for message app logs with common functionality.""" + + @abstractmethod + def get_app_mode_filter(self) -> Any: + """Return the filter conditions for the specific app mode.""" + pass + + @abstractmethod + def build_log_data(self, session: Session, message, conversation=None) -> dict: + """Build the log data dictionary for a specific app type.""" + pass + + @abstractmethod + def _build_base_query(self, app_model: App) -> Any: + """Build the base query for the specific app type.""" + pass + + @abstractmethod + def _build_total_count_query(self, app_model: App) -> Any: + """Build the total count query for the specific app type.""" + pass + + def _apply_base_filters( + self, + session, + query, + status, + created_at_before, + created_at_after, + created_by_end_user_session_id, + created_by_account, + ): + """Apply common filters to the query.""" + # Keyword search removed due to performance limitations + # ILIKE with wildcards cannot use B-tree indexes effectively + + if status: + query = query.where(Message.status == status) + + if created_at_before: + query = query.where(Message.created_at <= created_at_before) + + if created_at_after: + query = query.where(Message.created_at >= created_at_after) + + if created_by_end_user_session_id: + query = query.where(Message.from_end_user_id == created_by_end_user_session_id) + + if created_by_account: + account = self._get_account_by_email(session, created_by_account) + if not account: + return None, True # Signal that account was not found + query = query.where(Message.from_account_id == account.id) + + return query, False + + def _get_account_by_email(self, session, email): + """Get account by email from the database.""" + return session.scalar(select(Account).where(Account.email == email)) + + def _get_creator_info(self, session, message: Message): + """Get creator information for a message.""" + account_obj = None + end_user_obj = None + created_from = "api" + created_by_role = None + + if message.from_account_id: + account_obj = session.get(Account, message.from_account_id) + created_from = "web_app" + created_by_role = CreatorUserRole.ACCOUNT.value + elif message.from_end_user_id: + end_user_obj = session.get(EndUser, message.from_end_user_id) + created_from = "service_api" + created_by_role = CreatorUserRole.END_USER.value + + return account_obj, end_user_obj, created_from, created_by_role + + def get_paginate_app_logs( + self, + session: Session, + app_model: App, + status: str | None = None, + created_at_before: datetime | None = None, + created_at_after: datetime | None = None, + page: int = 1, + limit: int = 20, + created_by_end_user_session_id: str | None = None, + created_by_account: str | None = None, + ) -> dict: + """ + Get paginated app logs with token consumption information. + This is the main method that coordinates the log retrieval process. + """ + # Build base query + query = self._build_base_query(app_model) + + # Apply filters + query, account_not_found = self._apply_base_filters( + session, + query, + status, + created_at_before, + created_at_after, + created_by_end_user_session_id, + created_by_account, + ) + + # If account not found, return empty results + if account_not_found or query is None: + return self._empty_result(page, limit) + + # Build and execute total count query + total_query = self._build_total_count_query(app_model) + total_query, account_not_found = self._apply_base_filters( + session, + total_query, + status, + created_at_before, + created_at_after, + created_by_end_user_session_id, + created_by_account, + ) + + # If account not found in count query, return empty results + if account_not_found or total_query is None: + return self._empty_result(page, limit) + + # Get total count + total_result = session.execute(total_query).scalar() + total = total_result if total_result is not None else 0 + + # Apply pagination + offset = (page - 1) * limit + query = query.offset(offset).limit(limit) + + # Execute query + messages = session.execute(query).scalars().all() + + # Transform to log format + logs = [] + + conversation_ids = {msg.conversation_id for msg in messages if msg.conversation_id} + conversations = {} + if conversation_ids: + conversation_results = session.query(Conversation).filter(Conversation.id.in_(conversation_ids)).all() + conversations = {conv.id: conv for conv in conversation_results} + + for message in messages: + conversation = conversations.get(message.conversation_id) + log_data = self.build_log_data(session, message, conversation) + logs.append(log_data) + + has_more = offset + limit < total + + return { + "data": logs, + "has_more": has_more, + "limit": limit, + "total": total, + "page": page, + } + + def _empty_result(self, page, limit): + """Return empty result set.""" + return { + "data": [], + "has_more": False, + "limit": limit, + "total": 0, + "page": page, + } diff --git a/api/tests/integration_tests/controllers/console/app/test_chat_app_log_api.py b/api/tests/integration_tests/controllers/console/app/test_chat_app_log_api.py new file mode 100644 index 0000000000..89cb489563 --- /dev/null +++ b/api/tests/integration_tests/controllers/console/app/test_chat_app_log_api.py @@ -0,0 +1,329 @@ +"""Integration tests for Chat App Log API endpoints.""" + +import uuid + +import pytest + +from tests.integration_tests.controllers.console.app.test_feedback_api_basic import TestFeedbackApiBasic + + +class TestChatAppLogApiBasic(TestFeedbackApiBasic): + """Basic integration tests for Chat App Log API endpoints.""" + + def test_chat_app_logs_endpoint_exists(self, test_client, auth_header): + """Test that chat app logs endpoint exists and handles basic requests.""" + app_id = str(uuid.uuid4()) + + # Test endpoint exists (even if it fails, it should return 500 or 403, not 404) + response = test_client.get( + f"/console/api/apps/{app_id}/chat-app-logs", headers=auth_header, query_string={"page": 1, "limit": 20} + ) + + # Should not return 404 (endpoint exists) + assert response.status_code != 404 + + # Should return authentication or permission error, or success if app exists + assert response.status_code in [200, 401, 403, 500] + + def test_chat_app_logs_endpoint_with_parameters(self, test_client, auth_header): + """Test chat app logs endpoint with various query parameters.""" + app_id = str(uuid.uuid4()) + + # Test with all chat-specific parameters + params = { + "status": "normal", + "created_at__before": "2024-01-01T00:00:00Z", + "created_at__after": "2023-12-01T00:00:00Z", + "from_end_user_id": "user_session_456", + "created_by_account": "user@example.com", + "page": 2, + "limit": 15, + } + + response = test_client.get( + f"/console/api/apps/{app_id}/chat-app-logs", headers=auth_header, query_string=params + ) + + # Should not return 404 (endpoint exists) + assert response.status_code != 404 + + def test_chat_app_logs_endpoint_invalid_parameters(self, test_client, auth_header): + """Test chat app logs endpoint with invalid parameters.""" + app_id = str(uuid.uuid4()) + + # Test with invalid page number + response = test_client.get( + f"/console/api/apps/{app_id}/chat-app-logs", + headers=auth_header, + query_string={"page": 100000}, # Invalid: page should be <= 99999 + ) + + # Should return validation error + assert response.status_code == 400 + + # Test with invalid limit + response = test_client.get( + f"/console/api/apps/{app_id}/chat-app-logs", + headers=auth_header, + query_string={"limit": 101}, # Invalid: limit should be <= 100 + ) + + # Should return validation error + assert response.status_code == 400 + + # Test with negative page + response = test_client.get( + f"/console/api/apps/{app_id}/chat-app-logs", + headers=auth_header, + query_string={"page": -1}, # Invalid: page should be >= 1 + ) + + # Should return validation error + assert response.status_code == 400 + + # Test with invalid datetime format + response = test_client.get( + f"/console/api/apps/{app_id}/chat-app-logs", + headers=auth_header, + query_string={"created_at__before": "not-a-date"}, + ) + + # Should return validation error + assert response.status_code == 400 + + def test_chat_app_logs_endpoint_no_auth(self, test_client): + """Test chat app logs endpoint without authentication.""" + app_id = str(uuid.uuid4()) + + response = test_client.get(f"/console/api/apps/{app_id}/chat-app-logs", query_string={"page": 1, "limit": 20}) + + # Should return authentication error + assert response.status_code == 401 + + def test_chat_app_logs_response_structure(self, test_client, auth_header): + """Test that successful response has correct structure for chat logs.""" + app_id = str(uuid.uuid4()) + + response = test_client.get( + f"/console/api/apps/{app_id}/chat-app-logs", headers=auth_header, query_string={"page": 1, "limit": 20} + ) + + # If we get a successful response, verify structure + if response.status_code == 200: + data = response.get_json() + + # Verify response structure + assert "data" in data + assert "has_more" in data + assert "limit" in data + assert "total" in data + assert "page" in data + + # Verify data is a list + assert isinstance(data["data"], list) + + # If there are log entries, verify structure of each entry + if len(data["data"]) > 0: + entry = data["data"][0] + + # Chat logs should have conversation data + assert "conversation" in entry + assert "message" in entry + assert "created_from" in entry + assert "created_by_role" in entry + + # Conversation should have basic fields + conversation = entry["conversation"] + if conversation: # Can be None in some cases + assert "id" in conversation + assert "name" in conversation + assert "status" in conversation + + # Message should have token consumption fields + message = entry["message"] + assert "message_tokens" in message + assert "total_tokens" in message + assert "conversation_id" in message + assert "query" in message + assert "answer" in message + + def test_chat_app_logs_endpoint_different_app_modes(self, test_client, auth_header): + """Test that endpoint works for different chat app modes.""" + app_id = str(uuid.uuid4()) + + # This should work for chat app modes (CHAT, AGENT_CHAT, ADVANCED_CHAT) + response = test_client.get( + f"/console/api/apps/{app_id}/chat-app-logs", headers=auth_header, query_string={"page": 1, "limit": 20} + ) + + # Should not return 404 (endpoint exists) + # May return 403 if app doesn't exist or wrong mode, but not 404 + assert response.status_code != 404 + + def test_chat_app_logs_pagination_edge_cases(self, test_client, auth_header): + """Test pagination edge cases for chat app logs.""" + app_id = str(uuid.uuid4()) + + # Test minimum values + response = test_client.get( + f"/console/api/apps/{app_id}/chat-app-logs", headers=auth_header, query_string={"page": 1, "limit": 1} + ) + assert response.status_code in [200, 401, 403, 500] + + # Test maximum values + response = test_client.get( + f"/console/api/apps/{app_id}/chat-app-logs", headers=auth_header, query_string={"page": 99999, "limit": 100} + ) + assert response.status_code in [200, 401, 403, 500] + + def test_chat_app_logs_filtering_by_status(self, test_client, auth_header): + """Test filtering chat app logs by status.""" + app_id = str(uuid.uuid4()) + + # Test different status values + statuses = ["normal", "error", "finished", "running"] + + for status in statuses: + response = test_client.get( + f"/console/api/apps/{app_id}/chat-app-logs", + headers=auth_header, + query_string={"status": status, "page": 1, "limit": 20}, + ) + assert response.status_code in [200, 401, 403, 500] + + def test_chat_app_logs_filtering_by_dates(self, test_client, auth_header): + """Test filtering chat app logs by date ranges.""" + app_id = str(uuid.uuid4()) + + # Test date range filtering + response = test_client.get( + f"/console/api/apps/{app_id}/chat-app-logs", + headers=auth_header, + query_string={"created_at__before": "2024-01-01T00:00:00Z", "created_at__after": "2023-01-01T00:00:00Z"}, + ) + assert response.status_code in [200, 401, 403, 500] + + def test_chat_app_logs_filtering_by_users(self, test_client, auth_header): + """Test filtering chat app logs by user information.""" + app_id = str(uuid.uuid4()) + + # Test filtering by end user session ID + response = test_client.get( + f"/console/api/apps/{app_id}/chat-app-logs", + headers=auth_header, + query_string={"from_end_user_id": "test_session_123456"}, + ) + assert response.status_code in [200, 401, 403, 500] + + # Test filtering by account email + response = test_client.get( + f"/console/api/apps/{app_id}/chat-app-logs", + headers=auth_header, + query_string={"created_by_account": "user@example.com"}, + ) + assert response.status_code in [200, 401, 403, 500] + + # Test filtering by both + response = test_client.get( + f"/console/api/apps/{app_id}/chat-app-logs", + headers=auth_header, + query_string={"from_end_user_id": "test_session", "created_by_account": "user@example.com"}, + ) + assert response.status_code in [200, 401, 403, 500] + + def test_chat_app_logs_combined_filters(self, test_client, auth_header): + """Test chat app logs with multiple filters combined.""" + app_id = str(uuid.uuid4()) + + # Test combining multiple filters + response = test_client.get( + f"/console/api/apps/{app_id}/chat-app-logs", + headers=auth_header, + query_string={ + "status": "normal", + "created_at__before": "2024-01-01T00:00:00Z", + "from_end_user_id": "user_session", + "created_by_account": "support@company.com", + "page": 2, + "limit": 25, + }, + ) + assert response.status_code in [200, 401, 403, 500] + + def test_chat_app_logs_empty_parameters(self, test_client, auth_header): + """Test chat app logs with empty or null parameters.""" + app_id = str(uuid.uuid4()) + + # Test with empty string parameters (should be handled gracefully) + response = test_client.get( + f"/console/api/apps/{app_id}/chat-app-logs", + headers=auth_header, + query_string={"status": "", "from_end_user_id": ""}, + ) + # Empty strings should not cause server errors + assert response.status_code in [200, 401, 403, 500] + + def test_chat_app_logs_large_page_numbers(self, test_client, auth_header): + """Test chat app logs with very large page numbers.""" + app_id = str(uuid.uuid4()) + + # Test with a very large page number (should return empty results if app exists) + response = test_client.get( + f"/console/api/apps/{app_id}/chat-app-logs", headers=auth_header, query_string={"page": 999999, "limit": 20} + ) + assert response.status_code in [200, 401, 403, 500] + + @pytest.mark.parametrize("http_method", ["GET", "POST", "PUT", "DELETE", "PATCH"]) + def test_chat_app_logs_endpoint_http_methods(self, test_client, auth_header, http_method): + """Test that only GET method is supported for chat app logs.""" + app_id = str(uuid.uuid4()) + + if http_method == "GET": + response = test_client.get(f"/console/api/apps/{app_id}/chat-app-logs", headers=auth_header) + # GET should work (or return auth/permission errors) + assert response.status_code in [200, 401, 403, 500] + else: + # Other methods should return 405 Method Not Allowed + response = getattr(test_client, http_method.lower())( + f"/console/api/apps/{app_id}/chat-app-logs", headers=auth_header + ) + assert response.status_code == 405 + + def test_chat_app_logs_endpoint_content_type(self, test_client, auth_header): + """Test that chat app logs endpoint returns correct content type.""" + app_id = str(uuid.uuid4()) + + response = test_client.get( + f"/console/api/apps/{app_id}/chat-app-logs", headers=auth_header, query_string={"page": 1, "limit": 20} + ) + + # If successful, should return JSON + if response.status_code == 200: + assert response.content_type == "application/json" + + def test_chat_app_logs_query_string_encoding(self, test_client, auth_header): + """Test chat app logs with special characters in query parameters.""" + app_id = str(uuid.uuid4()) + + # Test with special characters in filter values + response = test_client.get( + f"/console/api/apps/{app_id}/chat-app-logs", + headers=auth_header, + query_string={ + "from_end_user_id": "session+with-special&chars", + "created_by_account": "user+test@example.com", + }, + ) + assert response.status_code in [200, 401, 403, 500] + + def test_chat_app_logs_unicode_support(self, test_client, auth_header): + """Test chat app logs with unicode characters in parameters.""" + app_id = str(uuid.uuid4()) + + # Test with unicode characters + response = test_client.get( + f"/console/api/apps/{app_id}/chat-app-logs", + headers=auth_header, + query_string={"from_end_user_id": "用户会话123", "created_by_account": "user@测试.com"}, + ) + assert response.status_code in [200, 401, 403, 500] diff --git a/api/tests/integration_tests/controllers/console/app/test_completion_app_log_api.py b/api/tests/integration_tests/controllers/console/app/test_completion_app_log_api.py new file mode 100644 index 0000000000..c7da86a767 --- /dev/null +++ b/api/tests/integration_tests/controllers/console/app/test_completion_app_log_api.py @@ -0,0 +1,232 @@ +"""Integration tests for Completion App Log API endpoints.""" + +import uuid + +import pytest + +from tests.integration_tests.controllers.console.app.test_feedback_api_basic import TestFeedbackApiBasic + + +class TestCompletionAppLogApiBasic(TestFeedbackApiBasic): + """Basic integration tests for Completion App Log API endpoints.""" + + def test_completion_app_logs_endpoint_exists(self, test_client, auth_header): + """Test that completion app logs endpoint exists and handles basic requests.""" + app_id = str(uuid.uuid4()) + + # Test endpoint exists (even if it fails, it should return 500 or 403, not 404) + response = test_client.get( + f"/console/api/apps/{app_id}/completion-app-logs", + headers=auth_header, + query_string={"page": 1, "limit": 20}, + ) + + # Should not return 404 (endpoint exists) + assert response.status_code != 404 + + # Should return authentication or permission error, or success if app exists + assert response.status_code in [200, 401, 403, 500] + + def test_completion_app_logs_endpoint_with_parameters(self, test_client, auth_header): + """Test completion app logs endpoint with various query parameters.""" + app_id = str(uuid.uuid4()) + + # Test with all parameters + params = { + "status": "normal", + "created_at__before": "2024-01-01T00:00:00Z", + "created_at__after": "2023-12-01T00:00:00Z", + "created_by_end_user_session_id": "test_session", + "created_by_account": "user@example.com", + "page": 1, + "limit": 10, + } + + response = test_client.get( + f"/console/api/apps/{app_id}/completion-app-logs", headers=auth_header, query_string=params + ) + + # Should not return 404 (endpoint exists) + assert response.status_code != 404 + + def test_completion_app_logs_endpoint_invalid_parameters(self, test_client, auth_header): + """Test completion app logs endpoint with invalid parameters.""" + app_id = str(uuid.uuid4()) + + # Test with invalid page number + response = test_client.get( + f"/console/api/apps/{app_id}/completion-app-logs", + headers=auth_header, + query_string={"page": 0}, # Invalid: page should be >= 1 + ) + + # Should return validation error + assert response.status_code == 400 + + # Test with invalid limit + response = test_client.get( + f"/console/api/apps/{app_id}/completion-app-logs", + headers=auth_header, + query_string={"limit": 0}, # Invalid: limit should be >= 1 + ) + + # Should return validation error + assert response.status_code == 400 + + # Test with limit too large + response = test_client.get( + f"/console/api/apps/{app_id}/completion-app-logs", + headers=auth_header, + query_string={"limit": 101}, # Invalid: limit should be <= 100 + ) + + # Should return validation error + assert response.status_code == 400 + + # Test with invalid datetime format + response = test_client.get( + f"/console/api/apps/{app_id}/completion-app-logs", + headers=auth_header, + query_string={"created_at__before": "invalid-date"}, + ) + + # Should return validation error + assert response.status_code == 400 + + def test_completion_app_logs_endpoint_no_auth(self, test_client): + """Test completion app logs endpoint without authentication.""" + app_id = str(uuid.uuid4()) + + response = test_client.get( + f"/console/api/apps/{app_id}/completion-app-logs", query_string={"page": 1, "limit": 20} + ) + + # Should return authentication error + assert response.status_code == 401 + + def test_completion_app_logs_response_structure(self, test_client, auth_header): + """Test that successful response has correct structure.""" + app_id = str(uuid.uuid4()) + + response = test_client.get( + f"/console/api/apps/{app_id}/completion-app-logs", + headers=auth_header, + query_string={"page": 1, "limit": 20}, + ) + + # If we get a successful response, verify structure + if response.status_code == 200: + data = response.get_json() + + # Verify response structure + assert "data" in data + assert "has_more" in data + assert "limit" in data + assert "total" in data + assert "page" in data + + # Verify data is a list + assert isinstance(data["data"], list) + + # Verify pagination fields are correct types + assert isinstance(data["has_more"], bool) + assert isinstance(data["limit"], int) + assert isinstance(data["total"], int) + assert isinstance(data["page"], int) + + def test_completion_app_logs_endpoint_different_app_modes(self, test_client, auth_header): + """Test that endpoint is accessible for completion app mode.""" + app_id = str(uuid.uuid4()) + + # This should work for completion app mode + response = test_client.get( + f"/console/api/apps/{app_id}/completion-app-logs", + headers=auth_header, + query_string={"page": 1, "limit": 20}, + ) + + # Should not return 404 (endpoint exists) + # May return 403 if app doesn't exist or wrong mode, but not 404 + assert response.status_code != 404 + + def test_completion_app_logs_pagination_parameters(self, test_client, auth_header): + """Test pagination parameters work correctly.""" + app_id = str(uuid.uuid4()) + + # Test different page sizes + for limit in [1, 5, 10, 20, 50, 100]: + response = test_client.get( + f"/console/api/apps/{app_id}/completion-app-logs", + headers=auth_header, + query_string={"page": 1, "limit": limit}, + ) + + # Should handle valid limits without errors (except auth/permission issues) + assert response.status_code in [200, 401, 403, 500] + + # Test different page numbers + for page in [1, 2, 5, 10]: + response = test_client.get( + f"/console/api/apps/{app_id}/completion-app-logs", + headers=auth_header, + query_string={"page": page, "limit": 20}, + ) + + # Should handle valid pages without errors (except auth/permission issues) + assert response.status_code in [200, 401, 403, 500] + + def test_completion_app_logs_filter_parameters(self, test_client, auth_header): + """Test filter parameters work correctly.""" + app_id = str(uuid.uuid4()) + + # Test status filter + response = test_client.get( + f"/console/api/apps/{app_id}/completion-app-logs", headers=auth_header, query_string={"status": "normal"} + ) + assert response.status_code in [200, 401, 403, 500] + + # Test date filters + response = test_client.get( + f"/console/api/apps/{app_id}/completion-app-logs", + headers=auth_header, + query_string={"created_at__before": "2024-01-01T00:00:00Z", "created_at__after": "2023-12-01T00:00:00Z"}, + ) + assert response.status_code in [200, 401, 403, 500] + + # Test user filters + response = test_client.get( + f"/console/api/apps/{app_id}/completion-app-logs", + headers=auth_header, + query_string={"created_by_end_user_session_id": "session_123", "created_by_account": "user@example.com"}, + ) + assert response.status_code in [200, 401, 403, 500] + + @pytest.mark.parametrize("http_method", ["GET", "POST", "PUT", "DELETE"]) + def test_completion_app_logs_endpoint_http_methods(self, test_client, auth_header, http_method): + """Test that only GET method is supported.""" + app_id = str(uuid.uuid4()) + + if http_method == "GET": + response = test_client.get(f"/console/api/apps/{app_id}/completion-app-logs", headers=auth_header) + # GET should work (or return auth/permission errors) + assert response.status_code in [200, 401, 403, 500] + else: + # Other methods should return 405 Method Not Allowed + response = getattr(test_client, http_method.lower())( + f"/console/api/apps/{app_id}/completion-app-logs", headers=auth_header + ) + assert response.status_code == 405 + + def test_completion_app_logs_endpoint_content_type(self, test_client, auth_header): + """Test that endpoint returns correct content type.""" + app_id = str(uuid.uuid4()) + + response = test_client.get( + f"/console/api/apps/{app_id}/completion-app-logs", + headers=auth_header, + query_string={"page": 1, "limit": 20}, + ) + + # If successful, should return JSON + if response.status_code == 200: + assert response.content_type == "application/json" diff --git a/api/tests/unit_tests/controllers/console/app/test_chat_app_log.py b/api/tests/unit_tests/controllers/console/app/test_chat_app_log.py new file mode 100644 index 0000000000..1f0e929bed --- /dev/null +++ b/api/tests/unit_tests/controllers/console/app/test_chat_app_log.py @@ -0,0 +1,408 @@ +"""Unit tests for Chat App Log API.""" + +import uuid +from datetime import UTC, datetime + +import pytest +from flask_restx import marshal +from pydantic import ValidationError + +from controllers.console.app.chat_app_log import ChatAppLogQuery +from fields.chat_app_log_fields import build_chat_app_log_pagination_model + + +class TestChatAppLogQuery: + """Test cases for ChatAppLogQuery model validation.""" + + def test_valid_query_with_all_parameters(self): + """Test query validation with all valid parameters.""" + query_data = { + "status": "normal", + "created_at__before": "2024-01-01T00:00:00Z", + "created_at__after": "2023-12-01T00:00:00Z", + "created_by_end_user_session_id": "session_456", + "created_by_account": "user@example.com", + "page": 3, + "limit": 30, + } + + # Should not raise any validation errors + query = ChatAppLogQuery.model_validate(query_data) + + assert query.status == "normal" + assert query.created_at__before == datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) + assert query.created_at__after == datetime(2023, 12, 1, 0, 0, 0, tzinfo=UTC) + assert query.created_by_end_user_session_id == "session_456" + assert query.created_by_account == "user@example.com" + assert query.page == 3 + assert query.limit == 30 + + def test_valid_query_with_minimal_parameters(self): + """Test query validation with minimal required parameters.""" + query_data = {"page": 1, "limit": 20} + + query = ChatAppLogQuery.model_validate(query_data) + + assert query.status is None + assert query.created_at__before is None + assert query.created_at__after is None + assert query.created_by_end_user_session_id is None + assert query.created_by_account is None + assert query.page == 1 + assert query.limit == 20 + + def test_invalid_page_number_too_low(self): + """Test validation with page number too low.""" + query_data = {"page": 0} # Page should be >= 1 + + with pytest.raises(ValidationError): + ChatAppLogQuery.model_validate(query_data) + + def test_invalid_page_number_too_high(self): + """Test validation with page number too high.""" + query_data = {"page": 100000} # Page should be <= 99999 + + with pytest.raises(ValidationError): + ChatAppLogQuery.model_validate(query_data) + + def test_invalid_limit_too_small(self): + """Test validation with limit too small.""" + query_data = {"limit": 0} # Limit should be >= 1 + + with pytest.raises(ValidationError): + ChatAppLogQuery.model_validate(query_data) + + def test_invalid_limit_too_large(self): + """Test validation with limit too large.""" + query_data = {"limit": 101} # Limit should be <= 100 + + with pytest.raises(ValidationError): + ChatAppLogQuery.model_validate(query_data) + + def test_invalid_datetime_format(self): + """Test validation with invalid datetime format.""" + query_data = {"created_at__before": "not-a-date"} + + with pytest.raises(ValidationError): + ChatAppLogQuery.model_validate(query_data) + + def test_empty_and_null_values(self): + """Test validation with empty and null values.""" + query_data = { + "status": "", + "created_at__before": "", + "created_at__after": None, + "created_by_end_user_session_id": "", + "created_by_account": None, + } + + query = ChatAppLogQuery.model_validate(query_data) + + # Empty strings should remain as empty strings, None should remain None + assert query.status == "" + assert query.created_at__before is None # Empty string becomes None for datetime + assert query.created_at__after is None + assert query.created_by_end_user_session_id == "" + assert query.created_by_account is None + + def test_edge_case_boundary_values(self): + """Test validation with boundary values.""" + # Test minimum valid values + query_data = {"page": 1, "limit": 1} + query = ChatAppLogQuery.model_validate(query_data) + assert query.page == 1 + assert query.limit == 1 + + # Test maximum valid values + query_data = {"page": 99999, "limit": 100} + query = ChatAppLogQuery.model_validate(query_data) + assert query.page == 99999 + assert query.limit == 100 + + def test_chat_specific_parameters(self): + """Test chat-specific parameter validation.""" + query_data = { + "created_by_end_user_session_id": "user_session_12345", + "status": "normal", + } + + query = ChatAppLogQuery.model_validate(query_data) + + assert query.created_by_end_user_session_id == "user_session_12345" + assert query.status == "normal" + + def test_datetime_parsing_various_formats(self): + """Test datetime parsing with various ISO formats.""" + valid_datetimes = [ + "2024-01-01T00:00:00Z", + "2024-01-01T12:30:45+00:00", + "2024-01-01T12:30:45-05:00", + ] + + for dt_str in valid_datetimes: + query_data = {"created_at__before": dt_str} + query = ChatAppLogQuery.model_validate(query_data) + assert query.created_at__before is not None + assert isinstance(query.created_at__before, datetime) + assert query.created_at__before.tzinfo is not None # Should have timezone info + + def test_model_to_dict_conversion(self): + """Test converting query model to dictionary.""" + query_data = { + "status": "error", + "created_by_end_user_session_id": "test_session", + "page": 5, + "limit": 15, + } + + query = ChatAppLogQuery.model_validate(query_data) + query_dict = query.model_dump() + + assert isinstance(query_dict, dict) + assert query_dict["status"] == "error" + assert query_dict["created_by_end_user_session_id"] == "test_session" + assert query_dict["page"] == 5 + assert query_dict["limit"] == 15 + + +class TestChatAppLogFields: + """Test cases for chat app log field serialization.""" + + def test_build_pagination_model(self): + """Test building the pagination model for chat logs.""" + from flask_restx import Namespace + + ns = Namespace("test") + model = build_chat_app_log_pagination_model(ns) + + assert model is not None + assert model.name == "ChatAppLogPagination" + + def test_marshal_empty_chat_response(self): + """Test marshaling an empty chat log response.""" + from flask_restx import Namespace + + ns = Namespace("test") + model = build_chat_app_log_pagination_model(ns) + + empty_response = { + "data": [], + "has_more": False, + "limit": 20, + "total": 0, + "page": 1, + } + + marshaled = marshal(empty_response, model) + + assert marshaled["data"] == [] + assert marshaled["has_more"] is False + assert marshaled["total"] == 0 + assert marshaled["page"] == 1 + + def test_marshal_chat_response_with_conversation(self): + """Test marshaling a response with conversation data.""" + from flask_restx import Namespace + + ns = Namespace("test") + model = build_chat_app_log_pagination_model(ns) + + test_response = { + "data": [ + { + "id": str(uuid.uuid4()), + "conversation": { + "id": str(uuid.uuid4()), + "name": "Customer Support Chat", + "status": "active", + }, + "message": { + "id": str(uuid.uuid4()), + "conversation_id": str(uuid.uuid4()), + "query": "How can I reset my password?", + "answer": "You can reset your password by clicking the 'Forgot Password' link.", + "status": "normal", + "message_tokens": 12, + "total_tokens": 35, + "created_at": datetime.utcnow(), + "error": None, + "provider_response_latency": 1.2, + "from_source": "web_app", + "from_end_user_id": str(uuid.uuid4()), + "from_account_id": None, + }, + "created_from": "web_app", + "created_by_role": "end_user", + "created_by_account": None, + "created_by_end_user": {"id": str(uuid.uuid4())}, + "created_at": datetime.utcnow(), + } + ], + "has_more": True, + "limit": 20, + "total": 150, + "page": 1, + } + + marshaled = marshal(test_response, model) + + # Verify structure + assert len(marshaled["data"]) == 1 + assert marshaled["has_more"] is True + assert marshaled["total"] == 150 + + # Verify conversation data + conversation = marshaled["data"][0]["conversation"] + assert conversation["name"] == "Customer Support Chat" + assert conversation["status"] == "active" + + # Verify message token fields + message = marshaled["data"][0]["message"] + assert "message_tokens" in message + assert "total_tokens" in message + assert message["message_tokens"] == 12 + assert message["total_tokens"] == 35 + + def test_marshal_chat_response_multiple_entries(self): + """Test marshaling response with multiple chat log entries.""" + from flask_restx import Namespace + + ns = Namespace("test") + model = build_chat_app_log_pagination_model(ns) + + # Create test data with multiple entries + test_data = [] + for i in range(3): + test_data.append( + { + "id": str(uuid.uuid4()), + "conversation": { + "id": str(uuid.uuid4()), + "name": f"Chat {i + 1}", + "status": "completed", + }, + "message": { + "id": str(uuid.uuid4()), + "conversation_id": str(uuid.uuid4()), + "query": f"Message {i + 1}", + "answer": f"Response {i + 1}", + "status": "normal", + "message_tokens": 5 + i, + "total_tokens": 15 + i * 2, + "created_at": datetime.utcnow(), + "error": None, + "provider_response_latency": 0.5 + i * 0.1, + "from_source": "api", + "from_end_user_id": None, + "from_account_id": str(uuid.uuid4()), + }, + "created_from": "api", + "created_by_role": "account", + "created_by_account": {"id": str(uuid.uuid4())}, + "created_by_end_user": None, + "created_at": datetime.utcnow(), + } + ) + + test_response = { + "data": test_data, + "has_more": False, + "limit": 20, + "total": 3, + "page": 1, + } + + marshaled = marshal(test_response, model) + + # Verify all entries are present + assert len(marshaled["data"]) == 3 + assert marshaled["total"] == 3 + + # Verify each entry has conversation and message data + for i, entry in enumerate(marshaled["data"]): + assert "conversation" in entry + assert "message" in entry + assert entry["conversation"]["name"] == f"Chat {i + 1}" + assert entry["message"]["query"] == f"Message {i + 1}" + assert entry["message"]["message_tokens"] == 5 + i + assert entry["message"]["total_tokens"] == 15 + i * 2 + + def test_marshal_response_with_creator_information(self): + """Test marshaling response with different creator types.""" + from flask_restx import Namespace + + ns = Namespace("test") + model = build_chat_app_log_pagination_model(ns) + + # Test data for different creator scenarios + scenarios = [ + { + "name": "Account created", + "created_from": "web_app", + "created_by_role": "account", + "created_by_account": {"id": str(uuid.uuid4()), "email": "user@example.com", "name": None}, + "created_by_end_user": None, + }, + { + "name": "End user created", + "created_from": "service_api", + "created_by_role": "end_user", + "created_by_account": None, + "created_by_end_user": { + "id": str(uuid.uuid4()), + "session_id": "session_123", + "type": None, + "is_anonymous": None, + }, + }, + { + "name": "Session created", + "created_from": "service_api", + "created_by_role": "end_user", + "created_by_account": None, + "created_by_end_user": None, + }, + ] + + for scenario in scenarios: + test_response = { + "data": [ + { + "id": str(uuid.uuid4()), + "conversation": { + "id": str(uuid.uuid4()), + "name": scenario["name"], + "status": "active", + }, + "message": { + "id": str(uuid.uuid4()), + "conversation_id": str(uuid.uuid4()), + "query": "Test query", + "answer": "Test answer", + "status": "normal", + "message_tokens": 8, + "total_tokens": 20, + "created_at": datetime.utcnow(), + "error": None, + "provider_response_latency": 0.8, + "from_source": "api", + "from_end_user_id": None, + "from_account_id": None, + }, + **{k: v for k, v in scenario.items() if k != "name"}, + "created_at": datetime.utcnow(), + } + ], + "has_more": False, + "limit": 20, + "total": 1, + "page": 1, + } + + marshaled = marshal(test_response, model) + entry = marshaled["data"][0] + + assert entry["created_from"] == scenario["created_from"] + assert entry["created_by_role"] == scenario["created_by_role"] + assert entry["created_by_account"] == scenario["created_by_account"] + assert entry["created_by_end_user"] == scenario["created_by_end_user"] diff --git a/api/tests/unit_tests/controllers/console/app/test_completion_app_log.py b/api/tests/unit_tests/controllers/console/app/test_completion_app_log.py new file mode 100644 index 0000000000..70e1925835 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/app/test_completion_app_log.py @@ -0,0 +1,236 @@ +"""Unit tests for Completion App Log API.""" + +import uuid +from datetime import UTC, datetime + +import pytest +from flask_restx import marshal +from pydantic import ValidationError + +from controllers.console.app.completion_app_log import CompletionAppLogQuery +from fields.completion_app_log_fields import build_completion_app_log_pagination_model + + +class TestCompletionAppLogQuery: + """Test cases for CompletionAppLogQuery model validation.""" + + def test_valid_query_with_all_parameters(self): + """Test query validation with all valid parameters.""" + query_data = { + "status": "normal", + "created_at__before": "2024-01-01T00:00:00Z", + "created_at__after": "2023-12-01T00:00:00Z", + "created_by_end_user_session_id": "session_123", + "created_by_account": "user@example.com", + "page": 2, + "limit": 50, + } + + # Should not raise any validation errors + query = CompletionAppLogQuery.model_validate(query_data) + + assert query.status == "normal" + assert query.created_at__before == datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) + assert query.created_at__after == datetime(2023, 12, 1, 0, 0, 0, tzinfo=UTC) + assert query.created_by_end_user_session_id == "session_123" + assert query.created_by_account == "user@example.com" + assert query.page == 2 + assert query.limit == 50 + + def test_valid_query_with_minimal_parameters(self): + """Test query validation with minimal required parameters.""" + query_data = {"page": 1, "limit": 20} + + query = CompletionAppLogQuery.model_validate(query_data) + + assert query.status is None + assert query.created_at__before is None + assert query.created_at__after is None + assert query.created_by_end_user_session_id is None + assert query.created_by_account is None + assert query.page == 1 + assert query.limit == 20 + + def test_invalid_page_number(self): + """Test validation with invalid page number.""" + query_data = {"page": 0} # Page should be >= 1 + + with pytest.raises(ValidationError): + CompletionAppLogQuery.model_validate(query_data) + + def test_invalid_limit_too_small(self): + """Test validation with limit too small.""" + query_data = {"limit": 0} # Limit should be >= 1 + + with pytest.raises(ValidationError): + CompletionAppLogQuery.model_validate(query_data) + + def test_invalid_limit_too_large(self): + """Test validation with limit too large.""" + query_data = {"limit": 101} # Limit should be <= 100 + + with pytest.raises(ValidationError): + CompletionAppLogQuery.model_validate(query_data) + + def test_invalid_datetime_format(self): + """Test validation with invalid datetime format.""" + query_data = {"created_at__before": "invalid-date"} + + with pytest.raises(ValidationError): + CompletionAppLogQuery.model_validate(query_data) + + def test_empty_datetime_values(self): + """Test validation with empty datetime values.""" + query_data = { + "created_at__before": "", + "created_at__after": None, + } + + query = CompletionAppLogQuery.model_validate(query_data) + + assert query.created_at__before is None + assert query.created_at__after is None + + def test_edge_case_page_and_limit_values(self): + """Test validation with edge case values for page and limit.""" + query_data = {"page": 1, "limit": 1} # Minimum valid values + + query = CompletionAppLogQuery.model_validate(query_data) + + assert query.page == 1 + assert query.limit == 1 + + query_data = {"page": 99999, "limit": 100} # Maximum valid values + + query = CompletionAppLogQuery.model_validate(query_data) + + assert query.page == 99999 + assert query.limit == 100 + + def test_status_various_values(self): + """Test validation with various status values.""" + valid_statuses = ["normal", "error", "finished", "running"] + + for status in valid_statuses: + query_data = {"status": status} + query = CompletionAppLogQuery.model_validate(query_data) + assert query.status == status + + def test_model_serialization(self): + """Test that the query model can be properly serialized.""" + query_data = { + "status": "normal", + "created_at__before": "2024-01-01T00:00:00Z", + "page": 1, + "limit": 20, + } + + query = CompletionAppLogQuery.model_validate(query_data) + + # Should be able to convert to dict without errors + query_dict = query.model_dump() + assert isinstance(query_dict, dict) + assert query_dict["status"] == "normal" + assert query_dict["page"] == 1 + assert query_dict["limit"] == 20 + + def test_default_values(self): + """Test that default values are applied correctly.""" + query = CompletionAppLogQuery.model_validate({}) + + assert query.status is None + assert query.created_at__before is None + assert query.created_at__after is None + assert query.created_by_end_user_session_id is None + assert query.created_by_account is None + assert query.page == 1 # Default page + assert query.limit == 20 # Default limit + + +class TestCompletionAppLogFields: + """Test cases for completion app log field serialization.""" + + def test_build_pagination_model(self): + """Test building the pagination model.""" + from flask_restx import Namespace + + ns = Namespace("test") + model = build_completion_app_log_pagination_model(ns) + + assert model is not None + assert model.name == "CompletionAppLogPagination" + + def test_marshal_empty_response(self): + """Test marshaling an empty response.""" + from flask_restx import Namespace + + ns = Namespace("test") + model = build_completion_app_log_pagination_model(ns) + + empty_response = { + "data": [], + "has_more": False, + "limit": 20, + "total": 0, + "page": 1, + } + + marshaled = marshal(empty_response, model) + + assert marshaled["data"] == [] + assert marshaled["has_more"] is False + assert marshaled["limit"] == 20 + assert marshaled["total"] == 0 + assert marshaled["page"] == 1 + + def test_marshal_response_with_data(self): + """Test marshaling a response with data.""" + from flask_restx import Namespace + + ns = Namespace("test") + model = build_completion_app_log_pagination_model(ns) + + test_response = { + "data": [ + { + "id": str(uuid.uuid4()), + "message": { + "id": str(uuid.uuid4()), + "query": "Test query", + "answer": "Test answer", + "status": "normal", + "message_tokens": 10, + "total_tokens": 25, + "created_at": datetime.utcnow(), + "error": None, + "provider_response_latency": 0.5, + "from_source": "api", + "from_end_user_id": None, + "from_account_id": None, + }, + "created_from": "api", + "created_by_role": None, + "created_by_account": None, + "created_by_end_user": None, + "created_at": datetime.utcnow(), + } + ], + "has_more": True, + "limit": 20, + "total": 100, + "page": 1, + } + + marshaled = marshal(test_response, model) + + assert len(marshaled["data"]) == 1 + assert marshaled["has_more"] is True + assert marshaled["total"] == 100 + assert marshaled["page"] == 1 + + # Verify message token fields are present + message = marshaled["data"][0]["message"] + assert "message_tokens" in message + assert "total_tokens" in message + assert message["message_tokens"] == 10 + assert message["total_tokens"] == 25 diff --git a/api/tests/unit_tests/services/test_chat_app_log_service.py b/api/tests/unit_tests/services/test_chat_app_log_service.py new file mode 100644 index 0000000000..2cf3e223b7 --- /dev/null +++ b/api/tests/unit_tests/services/test_chat_app_log_service.py @@ -0,0 +1,302 @@ +"""Unit tests for ChatAppLogService.""" + +import uuid +from datetime import datetime +from unittest.mock import MagicMock, patch + +from sqlalchemy.orm import Session + +from models import Account, App, Conversation, EndUser, Message +from models.model import AppMode +from services.chat_app_log_service import ChatAppLogService + + +class TestChatAppLogService: + """Test cases for ChatAppLogService.""" + + def setup_method(self): + """Set up test fixtures.""" + self.service = ChatAppLogService() + self.app_id = str(uuid.uuid4()) + self.app_model = MagicMock(spec=App) + self.app_model.id = self.app_id + self.session = MagicMock(spec=Session) + + def test_get_app_mode_filter(self): + """Test that app mode filter returns correct condition for chat modes.""" + filter_condition = self.service.get_app_mode_filter() + # Should return a SQLAlchemy filter condition for chat modes + assert filter_condition is not None + + @patch("services.chat_app_log_service.or_") + @patch("services.chat_app_log_service.Message") + def test_get_app_mode_filter_values(self, mock_message_model, mock_or): + """Test that app mode filter includes all chat modes.""" + # Setup mocks + mock_chat_condition = MagicMock() + mock_agent_chat_condition = MagicMock() + mock_advanced_chat_condition = MagicMock() + + mock_message_model.app_mode.__eq__.side_effect = lambda value: { + AppMode.CHAT.value: mock_chat_condition, + AppMode.AGENT_CHAT.value: mock_agent_chat_condition, + AppMode.ADVANCED_CHAT.value: mock_advanced_chat_condition, + }[value] + + mock_or.return_value = "combined_condition" + + # Call the method + result = self.service.get_app_mode_filter() + + # Verify or_ was called with all three chat mode conditions + mock_or.assert_called_once_with( + mock_chat_condition, + mock_agent_chat_condition, + mock_advanced_chat_condition, + ) + + assert result == "combined_condition" + + def test_build_log_data_with_conversation(self): + """Test building log data with conversation information.""" + # Create mock conversation + conversation = MagicMock(spec=Conversation) + conversation.id = str(uuid.uuid4()) + conversation.name = "Test Conversation" + conversation.status = "active" + + # Create mock message + message = MagicMock(spec=Message) + message.id = str(uuid.uuid4()) + message.conversation_id = conversation.id + message.query = "Hello" + message.answer = "Hi there!" + message.status = "normal" + message.message_tokens = 5 + message.total_tokens = 12 + message.created_at = datetime.utcnow() + message.error = None + message.provider_response_latency = 0.4 + message.from_source = "web_app" + message.from_end_user_id = None + message.from_account_id = str(uuid.uuid4()) + + # Create mock account + account = MagicMock(spec=Account) + account.id = message.from_account_id + + # Mock session.get to return the account + self.session.get.return_value = account + + # Build log data + result = self.service.build_log_data(self.session, message, conversation) + + # Verify the result structure + assert result["id"] == message.id + assert result["conversation"]["id"] == str(conversation.id) + assert result["conversation"]["name"] == conversation.name + assert result["conversation"]["status"] == conversation.status + assert result["message"]["id"] == str(message.id) + assert result["message"]["conversation_id"] == str(message.conversation_id) + assert result["message"]["query"] == message.query + assert result["message"]["answer"] == message.answer + assert result["message"]["message_tokens"] == message.message_tokens + assert result["message"]["total_tokens"] == message.total_tokens + assert result["created_from"] == "web_app" + assert result["created_by_role"] == "account" + assert result["created_by_account"] == account + assert result["created_by_end_user"] is None + + def test_build_log_data_without_conversation(self): + """Test building log data when conversation is not provided.""" + # Create mock message + message = MagicMock(spec=Message) + message.id = str(uuid.uuid4()) + message.conversation_id = str(uuid.uuid4()) + message.query = "Hello" + message.answer = "Hi there!" + message.status = "normal" + message.message_tokens = 8 + message.total_tokens = 15 + message.created_at = datetime.utcnow() + message.from_end_user_id = str(uuid.uuid4()) + message.from_account_id = None + + # Create mock end user + end_user = MagicMock(spec=EndUser) + end_user.id = message.from_end_user_id + + # Mock session.get to return the end user + def mock_get_side_effect(model, id): + if model == EndUser: + return end_user + elif model == Conversation: + return MagicMock(spec=Conversation, id=id, name="Test", status="active") + return None + + self.session.get.side_effect = mock_get_side_effect + + # Build log data without conversation + result = self.service.build_log_data(self.session, message) + + # Verify conversation is None when not provided + assert result["conversation"]["id"] is None + assert result["conversation"]["name"] is None + assert result["conversation"]["status"] is None + assert result["created_from"] == "service_api" + assert result["created_by_role"] == "end_user" + assert result["created_by_account"] is None + assert result["created_by_end_user"] == end_user + + def test_build_log_data_with_session_id(self): + """Test building log data when created by session ID.""" + # Create mock message + message = MagicMock(spec=Message) + message.id = str(uuid.uuid4()) + message.conversation_id = str(uuid.uuid4()) + message.query = "Hello" + message.answer = "Hi there!" + message.status = "normal" + message.message_tokens = 3 + message.total_tokens = 8 + message.created_at = datetime.utcnow() + message.from_end_user_id = None + message.from_account_id = None + message.created_by_end_user_session_id = "test_session_123" + + # Build log data + result = self.service.build_log_data(self.session, message) + + # Verify the result + assert result["message"]["total_tokens"] == message.total_tokens + assert result["created_from"] == "api" # Default when no from_end_user_id or from_account_id + assert result["created_by_role"] is None # Default when no specific creator + assert result["created_by_account"] is None + assert result["created_by_end_user"] is None + + # Verify session.get was not called for account/end user since using session ID + calls = self.session.get.call_args_list + account_calls = [call for call in calls if call[0][0] == Account] + end_user_calls = [call for call in calls if call[0][0] == EndUser] + assert len(account_calls) == 0 + assert len(end_user_calls) == 0 + + @patch.object(ChatAppLogService, "get_paginate_app_logs") + def test_get_paginate_chat_app_logs(self, mock_get_paginate_app_logs): + """Test the main pagination method.""" + # Setup mock return value + expected_result = { + "data": [{"id": "test_chat_log"}], + "has_more": True, + "limit": 10, + "total": 50, + "page": 2, + } + mock_get_paginate_app_logs.return_value = expected_result + + # Store datetime value to ensure consistency + test_datetime = datetime.utcnow() + + # Call the method + result = self.service.get_paginate_chat_app_logs( + session=self.session, + app_model=self.app_model, + status="normal", + created_at_before=test_datetime, + page=2, + limit=10, + created_by_end_user_session_id="session_123", + ) + + # Verify the result + assert result == expected_result + + # Verify the base method was called with correct parameters + mock_get_paginate_app_logs.assert_called_once_with( + session=self.session, + app_model=self.app_model, + status="normal", + created_at_before=test_datetime, + created_at_after=None, + page=2, + limit=10, + created_by_end_user_session_id="session_123", + created_by_account=None, + ) + + @patch("services.chat_app_log_service.select") + @patch("services.chat_app_log_service.Message") + @patch("services.chat_app_log_service.Conversation") + def test_build_base_query(self, mock_conversation_model, mock_message_model, mock_select): + """Test building the base query for chat apps.""" + # Setup mocks + mock_query = MagicMock() + mock_join = MagicMock() + mock_where = MagicMock() + mock_where.order_by.return_value = mock_query + mock_join.where.return_value = mock_where + mock_select.return_value.join.return_value = mock_join + + # Call the method + result = self.service._build_base_query(self.app_model) + + # Verify the query was built correctly + mock_select.assert_called_once_with(mock_message_model) + mock_select.return_value.join.assert_called_once_with( + mock_conversation_model, mock_message_model.conversation_id == mock_conversation_model.id + ) + mock_join.where.assert_called_once() + mock_where.order_by.assert_called_once() + + assert result == mock_query + + @patch("services.chat_app_log_service.select") + @patch("services.chat_app_log_service.func") + @patch("services.chat_app_log_service.Message") + @patch("services.chat_app_log_service.Conversation") + def test_build_total_count_query(self, mock_conversation_model, mock_message_model, mock_func, mock_select): + """Test building the total count query.""" + # Setup mocks + mock_count_query = MagicMock() + mock_join = MagicMock() + mock_join.where.return_value = mock_count_query + mock_select.return_value.join.return_value = mock_join + + # Call the method + result = self.service._build_total_count_query(self.app_model) + + # Verify the count query was built correctly + mock_func.count.assert_called_once_with(mock_message_model.id) + mock_select.return_value.join.assert_called_once_with( + mock_conversation_model, mock_message_model.conversation_id == mock_conversation_model.id + ) + mock_join.where.assert_called_once() + + assert result == mock_count_query + + def test_build_log_data_token_consumption_fields(self): + """Test that token consumption fields are properly included.""" + # Create mock message with token data + message = MagicMock(spec=Message) + message.id = str(uuid.uuid4()) + message.conversation_id = str(uuid.uuid4()) + message.query = "What is the weather?" + message.answer = "The weather is sunny today." + message.status = "normal" + message.message_tokens = 7 # Input tokens + message.total_tokens = 20 # Total tokens (input + output) + message.created_at = datetime.utcnow() + message.from_source = "api" + message.from_end_user_id = None + message.from_account_id = None + message.created_by_end_user_session_id = "web_session" + + # Build log data + result = self.service.build_log_data(self.session, message) + + # Verify token consumption fields are present and correct + assert "message" in result + assert result["message"]["message_tokens"] == 7 + assert result["message"]["total_tokens"] == 20 + assert result["message"]["query"] == message.query + assert result["message"]["answer"] == message.answer diff --git a/api/tests/unit_tests/services/test_completion_app_log_service.py b/api/tests/unit_tests/services/test_completion_app_log_service.py new file mode 100644 index 0000000000..4bf9a6c5b8 --- /dev/null +++ b/api/tests/unit_tests/services/test_completion_app_log_service.py @@ -0,0 +1,219 @@ +"""Unit tests for CompletionAppLogService.""" + +import uuid +from datetime import datetime +from unittest.mock import MagicMock, patch + +from sqlalchemy.orm import Session + +from models import Account, App, EndUser, Message +from services.completion_app_log_service import CompletionAppLogService + + +class TestCompletionAppLogService: + """Test cases for CompletionAppLogService.""" + + def setup_method(self): + """Set up test fixtures.""" + self.service = CompletionAppLogService() + self.app_id = str(uuid.uuid4()) + self.app_model = MagicMock(spec=App) + self.app_model.id = self.app_id + self.session = MagicMock(spec=Session) + + def test_get_app_mode_filter(self): + """Test that app mode filter returns correct condition.""" + filter_condition = self.service.get_app_mode_filter() + # Should return a SQLAlchemy filter condition for COMPLETION mode + assert filter_condition is not None + + def test_build_log_data_with_account_creator(self): + """Test building log data when message was created by an account.""" + # Create mock message + message = MagicMock(spec=Message) + message.id = str(uuid.uuid4()) + message.query = "Test query" + message.answer = "Test answer" + message.status = "normal" + message.message_tokens = 10 + message.total_tokens = 25 + message.created_at = datetime.utcnow() + message.error = None + message.provider_response_latency = 0.5 + message.from_source = "api" + message.from_end_user_id = None + message.from_account_id = str(uuid.uuid4()) + + # Create mock account + account = MagicMock(spec=Account) + account.id = message.from_account_id + + # Mock session.get to return the account + self.session.get.return_value = account + + # Build log data + result = self.service.build_log_data(self.session, message) + + # Verify the result structure + assert result["id"] == message.id + assert result["message"]["id"] == message.id + assert result["message"]["query"] == message.query + assert result["message"]["answer"] == message.answer + assert result["message"]["status"] == message.status + assert result["message"]["message_tokens"] == message.message_tokens + assert result["message"]["total_tokens"] == message.total_tokens + assert result["message"]["created_at"] == message.created_at + assert result["message"]["error"] == message.error + assert result["message"]["provider_response_latency"] == message.provider_response_latency + assert result["created_from"] == "web_app" + assert result["created_by_role"] == "account" + assert result["created_by_account"] == account + assert result["created_by_end_user"] is None + + # Verify session.get was called with correct parameters + self.session.get.assert_called_with(Account, message.from_account_id) + + def test_build_log_data_with_end_user_creator(self): + """Test building log data when message was created by an end user.""" + # Create mock message + message = MagicMock(spec=Message) + message.id = str(uuid.uuid4()) + message.query = "Test query" + message.answer = "Test answer" + message.status = "normal" + message.message_tokens = 15 + message.total_tokens = 30 + message.created_at = datetime.utcnow() + message.error = None + message.provider_response_latency = 0.3 + message.from_source = "api" + message.from_end_user_id = str(uuid.uuid4()) + message.from_account_id = None + + # Create mock end user + end_user = MagicMock(spec=EndUser) + end_user.id = message.from_end_user_id + + # Mock session.get to return the end user + self.session.get.return_value = end_user + + # Build log data + result = self.service.build_log_data(self.session, message) + + # Verify the result structure + assert result["id"] == message.id + assert result["message"]["total_tokens"] == message.total_tokens + assert result["created_from"] == "service_api" + assert result["created_by_role"] == "end_user" + assert result["created_by_account"] is None + assert result["created_by_end_user"] == end_user + + # Verify session.get was called with correct parameters + self.session.get.assert_called_with(EndUser, message.from_end_user_id) + + def test_build_log_data_with_session_creator(self): + """Test building log data when message was created by session.""" + # Create mock message + message = MagicMock(spec=Message) + message.id = str(uuid.uuid4()) + message.query = "Test query" + message.answer = "Test answer" + message.status = "normal" + message.message_tokens = 20 + message.total_tokens = 40 + message.created_at = datetime.utcnow() + message.error = None + message.provider_response_latency = 0.8 + message.from_source = "api" + message.from_end_user_id = None + message.from_account_id = None + + # Build log data + result = self.service.build_log_data(self.session, message) + + # Verify the result structure + assert result["id"] == message.id + assert result["message"]["total_tokens"] == message.total_tokens + assert result["created_from"] == "api" # Default when no from_end_user_id or from_account_id + assert result["created_by_role"] is None # Default when no specific creator + assert result["created_by_account"] is None + assert result["created_by_end_user"] is None + + # Verify session.get was not called + self.session.get.assert_not_called() + + @patch.object(CompletionAppLogService, "get_paginate_app_logs") + def test_get_paginate_completion_app_logs(self, mock_get_paginate_app_logs): + """Test the main pagination method.""" + # Setup mock return value + expected_result = { + "data": [{"id": "test"}], + "has_more": False, + "limit": 20, + "total": 1, + "page": 1, + } + mock_get_paginate_app_logs.return_value = expected_result + + # Call the method + result = self.service.get_paginate_completion_app_logs( + session=self.session, + app_model=self.app_model, + status="normal", + page=1, + limit=20, + ) + + # Verify the result + assert result == expected_result + + # Verify the base method was called with correct parameters + mock_get_paginate_app_logs.assert_called_once_with( + session=self.session, + app_model=self.app_model, + status="normal", + created_at_before=None, + created_at_after=None, + page=1, + limit=20, + created_by_end_user_session_id=None, + created_by_account=None, + ) + + @patch("services.completion_app_log_service.select") + @patch("services.completion_app_log_service.Message") + def test_build_base_query(self, mock_message_model, mock_select): + """Test building the base query for completion apps.""" + # Setup mocks + mock_query = MagicMock() + mock_where = MagicMock() + mock_where.order_by.return_value = mock_query + mock_select.return_value.where.return_value = mock_where + + # Call the method + result = self.service._build_base_query(self.app_model) + + # Verify the query was built correctly + mock_select.assert_called_once_with(mock_message_model) + mock_select.return_value.where.assert_called_once() + mock_where.order_by.assert_called_once() + + assert result == mock_query + + @patch("services.completion_app_log_service.select") + @patch("services.completion_app_log_service.func") + @patch("services.completion_app_log_service.Message") + def test_build_total_count_query(self, mock_message_model, mock_func, mock_select): + """Test building the total count query.""" + # Setup mocks + mock_count_query = MagicMock() + mock_select.return_value.where.return_value = mock_count_query + + # Call the method + result = self.service._build_total_count_query(self.app_model) + + # Verify the count query was built correctly + mock_func.count.assert_called_once_with(mock_message_model.id) + mock_select.return_value.where.assert_called_once() + + assert result == mock_count_query From b8067f9c9e3d2a34044fd5015644eb19edd1293a Mon Sep 17 00:00:00 2001 From: fatelei Date: Mon, 29 Dec 2025 16:26:33 +0800 Subject: [PATCH 2/3] fix: fix lint issue --- api/services/chat_app_log_service.py | 1 - 1 file changed, 1 deletion(-) diff --git a/api/services/chat_app_log_service.py b/api/services/chat_app_log_service.py index 300e2beab3..7c0a16bc53 100644 --- a/api/services/chat_app_log_service.py +++ b/api/services/chat_app_log_service.py @@ -77,7 +77,6 @@ class ChatAppLogService(MessageAppLogServiceBase): "answer": message.answer, "status": message.status, "message_tokens": message.message_tokens, - "total_tokens": message.total_tokens, "created_at": message.created_at, "error": message.error, "provider_response_latency": message.provider_response_latency, From 7e6106dfb2efbba4bda79d87c933e8be80beab28 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 29 Dec 2025 08:31:06 +0000 Subject: [PATCH 3/3] [autofix.ci] apply automated fixes --- api/services/message_app_log_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/services/message_app_log_service.py b/api/services/message_app_log_service.py index ff07bbef91..0b1c745e1d 100644 --- a/api/services/message_app_log_service.py +++ b/api/services/message_app_log_service.py @@ -155,7 +155,7 @@ class MessageAppLogServiceBase(ABC): conversation_ids = {msg.conversation_id for msg in messages if msg.conversation_id} conversations = {} if conversation_ids: - conversation_results = session.query(Conversation).filter(Conversation.id.in_(conversation_ids)).all() + conversation_results = session.query(Conversation).where(Conversation.id.in_(conversation_ids)).all() conversations = {conv.id: conv for conv in conversation_results} for message in messages: