feat: add api to retrieving logs from text generation applications and chat applications

This commit is contained in:
fatelei 2025-12-08 17:52:29 +08:00
parent 2c919efa69
commit c47dc8f18b
No known key found for this signature in database
GPG Key ID: 2F91DA05646F4EED
14 changed files with 2443 additions and 0 deletions

View File

@ -51,7 +51,9 @@ from .app import (
annotation, annotation,
app, app,
audio, audio,
chat_app_log,
completion, completion,
completion_app_log,
conversation, conversation,
conversation_variables, conversation_variables,
generator, generator,
@ -147,7 +149,9 @@ __all__ = [
"audio", "audio",
"billing", "billing",
"bp", "bp",
"chat_app_log",
"completion", "completion",
"completion_app_log",
"compliance", "compliance",
"console_ns", "console_ns",
"conversation", "conversation",

View File

@ -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/<uuid:app_id>/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

View File

@ -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/<uuid:app_id>/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

View File

@ -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)

View File

@ -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)

View File

@ -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(),
)
)
)

View File

@ -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(),
)
)

View File

@ -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,
}

View File

@ -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]

View File

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

View File

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

View File

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

View File

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

View File

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