test: migrate media and execution service sessions and ORM models to SQLite (#40552)

This commit is contained in:
Asuka Minato
2026-08-20 05:11:46 +00:00
committed by GitHub
parent 2155adff5a
commit e27eb51f8a
7 changed files with 408 additions and 221 deletions
@@ -2,12 +2,12 @@ from __future__ import annotations
import io
from inspect import unwrap
from types import SimpleNamespace
from unittest.mock import patch
from uuid import UUID
import pytest
from flask import Flask
from sqlalchemy.orm import Session
from werkzeug.datastructures import FileStorage
from werkzeug.exceptions import Forbidden, InternalServerError
@@ -34,7 +34,7 @@ from controllers.console.app.error import (
)
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from graphon.model_runtime.errors.invoke import InvokeError
from models import AppMode
from models import Account, App, AppMode
from models.agent import AgentConfigDraftType
from models.agent_config_entities import AgentSoulConfig
from services.agent.composer_service import AgentComposerService
@@ -56,11 +56,30 @@ def _file_data():
return FileStorage(stream=io.BytesIO(b"audio"), filename="audio.wav", content_type="audio/wav")
def _app(*, app_id: str = "a1", tenant_id: str = "tenant-1") -> App:
return App(
id=app_id,
tenant_id=tenant_id,
name="Audio app",
description="",
mode=AppMode.CHAT,
enable_site=True,
enable_api=True,
max_active_requests=0,
)
def _account(account_id: str = "account-1") -> Account:
account = Account(name="Audio account", email=f"{account_id}@example.com")
account.id = account_id
return account
def test_console_audio_api_success(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(AudioService, "transcript_asr", lambda **_kwargs: {"text": "ok"})
api = ChatMessageAudioApi()
handler = unwrap(api.post)
app_model = SimpleNamespace(id="a1")
app_model = _app()
with app.test_request_context("/console/api/apps/app/audio-to-text", method="POST", data={"file": _file_data()}):
response = handler(api, app_model=app_model)
@@ -72,9 +91,11 @@ def test_console_audio_api_accepts_published_agent_apps() -> None:
assert AppMode.AGENT in audio_module._CONSOLE_AUDIO_TRANSCRIPT_APP_MODES
def test_agent_console_audio_api_uses_agent_draft(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
def test_agent_console_audio_api_uses_agent_draft(
app: Flask, monkeypatch: pytest.MonkeyPatch, unbound_session: Session
) -> None:
agent_id = UUID("019ef3d2-b24c-7803-b428-18b5ee8fb853")
app_model = SimpleNamespace(id="backing-app-1")
app_model = _app(app_id="backing-app-1")
agent_soul = AgentSoulConfig.model_validate({"app_features": {"speech_to_text": {"enabled": True}}})
calls: dict[str, object] = {}
@@ -100,8 +121,8 @@ def test_agent_console_audio_api_uses_agent_draft(app: Flask, monkeypatch: pytes
api = AgentChatMessageAudioApi()
handler = unwrap(api.post)
session = SimpleNamespace()
current_user = SimpleNamespace(id="account-1")
session = unbound_session
current_user = _account()
with app.test_request_context(
f"/console/api/agent/{agent_id}/audio-to-text",
method="POST",
@@ -140,13 +161,15 @@ def test_agent_console_audio_api_uses_agent_draft(app: Flask, monkeypatch: pytes
}
def test_agent_console_audio_api_defaults_to_normal_draft(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
def test_agent_console_audio_api_defaults_to_normal_draft(
app: Flask, monkeypatch: pytest.MonkeyPatch, unbound_session: Session
) -> None:
agent_id = UUID("019ef3d2-b24c-7803-b428-18b5ee8fb853")
captured: dict[str, object] = {}
monkeypatch.setattr(
audio_module,
"resolve_agent_runtime_app_model",
lambda **_kwargs: SimpleNamespace(id="backing-app-1"),
lambda **_kwargs: _app(app_id="backing-app-1"),
)
def load_agent_soul_for_debug(**kwargs):
@@ -165,9 +188,9 @@ def test_agent_console_audio_api_defaults_to_normal_draft(app: Flask, monkeypatc
):
response = handler(
api,
session=SimpleNamespace(),
session=unbound_session,
current_tenant_id="tenant-1",
current_user=SimpleNamespace(id="account-1"),
current_user=_account(),
agent_id=agent_id,
)
@@ -175,9 +198,11 @@ def test_agent_console_audio_api_defaults_to_normal_draft(app: Flask, monkeypatc
assert captured["draft_type"] == AgentConfigDraftType.DRAFT
def test_agent_console_audio_api_checks_rbac_with_backing_app_id(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
def test_agent_console_audio_api_checks_rbac_with_backing_app_id(
app: Flask, monkeypatch: pytest.MonkeyPatch, unbound_session: Session
) -> None:
agent_id = UUID("019ef3d2-b24c-7803-b428-18b5ee8fb853")
app_model = SimpleNamespace(id="backing-app-1")
app_model = _app(app_id="backing-app-1")
soul_loaded = False
monkeypatch.setattr(audio_module, "resolve_agent_runtime_app_model", lambda **_kwargs: app_model)
@@ -204,21 +229,23 @@ def test_agent_console_audio_api_checks_rbac_with_backing_app_id(app: Flask, mon
with pytest.raises(Forbidden):
handler(
api,
session=SimpleNamespace(),
session=unbound_session,
current_tenant_id="tenant-1",
current_user=SimpleNamespace(id="account-1"),
current_user=_account(),
agent_id=agent_id,
)
assert soul_loaded is False
def test_agent_console_audio_api_preserves_missing_build_draft_404(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
def test_agent_console_audio_api_preserves_missing_build_draft_404(
app: Flask, monkeypatch: pytest.MonkeyPatch, unbound_session: Session
) -> None:
agent_id = UUID("019ef3d2-b24c-7803-b428-18b5ee8fb853")
monkeypatch.setattr(
audio_module,
"resolve_agent_runtime_app_model",
lambda **_kwargs: SimpleNamespace(id="backing-app-1"),
lambda **_kwargs: _app(app_id="backing-app-1"),
)
monkeypatch.setattr(
AgentComposerService,
@@ -236,9 +263,9 @@ def test_agent_console_audio_api_preserves_missing_build_draft_404(app: Flask, m
with pytest.raises(AgentVersionNotFoundError):
handler(
api,
session=SimpleNamespace(),
session=unbound_session,
current_tenant_id="tenant-1",
current_user=SimpleNamespace(id="account-1"),
current_user=_account(),
agent_id=agent_id,
)
@@ -262,7 +289,7 @@ def test_console_audio_api_error_mapping(app: Flask, monkeypatch: pytest.MonkeyP
monkeypatch.setattr(AudioService, "transcript_asr", lambda **_kwargs: (_ for _ in ()).throw(exc))
api = ChatMessageAudioApi()
handler = unwrap(api.post)
app_model = SimpleNamespace(id="a1")
app_model = _app()
with app.test_request_context("/console/api/apps/app/audio-to-text", method="POST", data={"file": _file_data()}):
with pytest.raises(expected):
@@ -273,7 +300,7 @@ def test_console_audio_api_unhandled_error(app: Flask, monkeypatch: pytest.Monke
monkeypatch.setattr(AudioService, "transcript_asr", lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("boom")))
api = ChatMessageAudioApi()
handler = unwrap(api.post)
app_model = SimpleNamespace(id="a1")
app_model = _app()
with app.test_request_context("/console/api/apps/app/audio-to-text", method="POST", data={"file": _file_data()}):
with pytest.raises(InternalServerError):
@@ -285,7 +312,7 @@ def test_console_text_api_success(app: Flask, monkeypatch: pytest.MonkeyPatch) -
api = ChatMessageTextApi()
handler = unwrap(api.post)
app_model = SimpleNamespace(id="a1")
app_model = _app()
with app.test_request_context(
"/console/api/apps/app/text-to-audio",
@@ -300,7 +327,7 @@ def test_console_text_api_success(app: Flask, monkeypatch: pytest.MonkeyPatch) -
def test_console_text_api_builds_message_ref(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
api = ChatMessageTextApi()
handler = unwrap(api.post)
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
app_model = _app(app_id="app-1")
calls = {}
def fake_transcript_tts(**kwargs):
@@ -315,7 +342,7 @@ def test_console_text_api_builds_message_ref(app: Flask, monkeypatch: pytest.Mon
method="POST",
json={"text": "hello", "message_id": "message-1"},
),
patch("controllers.console.app.audio.current_user", SimpleNamespace(id="account-1")),
patch("controllers.console.app.audio.current_user", _account()),
):
response = handler(api, TextToSpeechPayload(text="hello", message_id="message-1"), app_model=app_model)
@@ -328,7 +355,7 @@ def test_console_text_api_error_mapping(app: Flask, monkeypatch: pytest.MonkeyPa
api = ChatMessageTextApi()
handler = unwrap(api.post)
app_model = SimpleNamespace(id="a1")
app_model = _app()
with app.test_request_context(
"/console/api/apps/app/text-to-audio",
@@ -345,7 +372,7 @@ def test_console_text_modes_success(app: Flask, monkeypatch: pytest.MonkeyPatch)
api = TextModesApi()
handler = unwrap(api.get)
app_model = SimpleNamespace(tenant_id="t1")
app_model = _app(tenant_id="t1")
with app.test_request_context("/console/api/apps/app/text-to-audio/voices?language=en", method="GET"):
response = handler(api, TextToSpeechVoiceQuery(language="en-US"), app_model=app_model)
@@ -362,7 +389,7 @@ def test_console_text_modes_language_error(app: Flask, monkeypatch: pytest.Monke
api = TextModesApi()
handler = unwrap(api.get)
app_model = SimpleNamespace(tenant_id="t1")
app_model = _app(tenant_id="t1")
with app.test_request_context("/console/api/apps/app/text-to-audio/voices?language=en", method="GET"):
with pytest.raises(AppUnavailableError):
@@ -376,7 +403,7 @@ def test_audio_to_text_success(app: Flask, monkeypatch: pytest.MonkeyPatch) -> N
response_payload = {"text": "hello"}
monkeypatch.setattr(AudioService, "transcript_asr", lambda **_kwargs: response_payload)
app_model = SimpleNamespace(id="app-1")
app_model = _app(app_id="app-1")
data = {"file": (io.BytesIO(b"x"), "sample.wav")}
with app.test_request_context(
@@ -400,7 +427,7 @@ def test_audio_to_text_maps_audio_too_large(app: Flask, monkeypatch: pytest.Monk
lambda **_kwargs: (_ for _ in ()).throw(AudioTooLargeServiceError("too large")),
)
app_model = SimpleNamespace(id="app-1")
app_model = _app(app_id="app-1")
data = {"file": (io.BytesIO(b"x"), "sample.wav")}
with app.test_request_context(
@@ -419,7 +446,7 @@ def test_text_to_audio_success(app: Flask, monkeypatch: pytest.MonkeyPatch) -> N
monkeypatch.setattr(AudioService, "transcript_tts", lambda **_kwargs: {"audio": "ok"})
app_model = SimpleNamespace(id="app-1")
app_model = _app(app_id="app-1")
with app.test_request_context(
"/console/api/apps/app-1/text-to-audio",
@@ -438,7 +465,7 @@ def test_text_to_audio_voices_success(app: Flask, monkeypatch: pytest.MonkeyPatc
expected_voices = [{"name": "Voice 1", "value": "voice-1"}]
monkeypatch.setattr(AudioService, "transcript_tts_voices", lambda **_kwargs: expected_voices)
app_model = SimpleNamespace(tenant_id="tenant-1")
app_model = _app()
with app.test_request_context(
"/console/api/apps/app-1/text-to-audio/voices",
@@ -456,7 +483,7 @@ def test_audio_to_text_with_invalid_file(app: Flask, monkeypatch: pytest.MonkeyP
monkeypatch.setattr(AudioService, "transcript_asr", lambda **_kwargs: {"text": "test"})
app_model = SimpleNamespace(id="app-1")
app_model = _app(app_id="app-1")
data = {"file": (io.BytesIO(b"invalid"), "sample.xyz")}
with app.test_request_context(
@@ -476,7 +503,7 @@ def test_text_to_audio_with_language_param(app: Flask, monkeypatch: pytest.Monke
monkeypatch.setattr(AudioService, "transcript_tts", lambda **_kwargs: {"audio": "test"})
app_model = SimpleNamespace(id="app-1")
app_model = _app(app_id="app-1")
with app.test_request_context(
"/console/api/apps/app-1/text-to-audio",
@@ -497,7 +524,7 @@ def test_text_to_audio_voices_with_language_filter(app: Flask, monkeypatch: pyte
lambda **_kwargs: [{"name": "Voice 1", "value": "voice-1"}],
)
app_model = SimpleNamespace(tenant_id="tenant-1")
app_model = _app()
with app.test_request_context(
"/console/api/apps/app-1/text-to-audio/voices?language=en-US",
@@ -10,7 +10,6 @@ Tests coverage for:
import io
import uuid
from inspect import unwrap
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
@@ -34,6 +33,8 @@ from controllers.service_api.app.error import (
)
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from graphon.model_runtime.errors.invoke import InvokeError
from models.enums import EndUserType
from models.model import App, AppMode, EndUser
from services.app_ref_service import AppRef, MessageRef
from services.audio_service import AudioService
from services.errors.app_model_config import AppModelConfigBrokenError
@@ -50,6 +51,31 @@ def _file_data():
return FileStorage(stream=io.BytesIO(b"audio"), filename="audio.wav", content_type="audio/wav")
def _app(*, app_id: str = "a1", tenant_id: str = "tenant-1") -> App:
return App(
id=app_id,
tenant_id=tenant_id,
name="Audio app",
description="",
mode=AppMode.CHAT,
enable_site=True,
enable_api=True,
max_active_requests=0,
)
def _end_user(*, end_user_id: str = "u1", external_user_id: str | None = None) -> EndUser:
return EndUser(
id=end_user_id,
tenant_id="tenant-1",
app_id="a1",
type=EndUserType.SERVICE_API,
external_user_id=external_user_id,
name="Audio user",
session_id=f"session-{end_user_id}",
)
# ---------------------------------------------------------------------------
# Pydantic Model Tests
# ---------------------------------------------------------------------------
@@ -197,8 +223,8 @@ class TestAudioApi:
monkeypatch.setattr(AudioService, "transcript_asr", lambda **_kwargs: {"text": "ok"})
api = AudioApi()
handler = unwrap(api.post)
app_model = SimpleNamespace(id="a1")
end_user = SimpleNamespace(id="u1")
app_model = _app()
end_user = _end_user()
with app.test_request_context("/audio-to-text", method="POST", data={"file": _file_data()}):
response = handler(api, app_model=app_model, end_user=end_user)
@@ -224,8 +250,8 @@ class TestAudioApi:
monkeypatch.setattr(AudioService, "transcript_asr", lambda **_kwargs: (_ for _ in ()).throw(exc))
api = AudioApi()
handler = unwrap(api.post)
app_model = SimpleNamespace(id="a1")
end_user = SimpleNamespace(id="u1")
app_model = _app()
end_user = _end_user()
with app.test_request_context("/audio-to-text", method="POST", data={"file": _file_data()}):
with pytest.raises(expected):
@@ -237,8 +263,8 @@ class TestAudioApi:
)
api = AudioApi()
handler = unwrap(api.post)
app_model = SimpleNamespace(id="a1")
end_user = SimpleNamespace(id="u1")
app_model = _app()
end_user = _end_user()
with app.test_request_context("/audio-to-text", method="POST", data={"file": _file_data()}):
with pytest.raises(InternalServerError):
@@ -251,8 +277,8 @@ class TestTextApi:
api = TextApi()
handler = unwrap(api.post)
app_model = SimpleNamespace(id="a1")
end_user = SimpleNamespace(id="end-user-1", external_user_id="ext")
app_model = _app()
end_user = _end_user(end_user_id="end-user-1", external_user_id="ext")
with app.test_request_context(
"/text-to-audio",
@@ -274,8 +300,8 @@ class TestTextApi:
api = TextApi()
handler = unwrap(api.post)
app_model = SimpleNamespace(id="a1", tenant_id="tenant-1")
end_user = SimpleNamespace(id="end-user-1", external_user_id="ext")
app_model = _app()
end_user = _end_user(end_user_id="end-user-1", external_user_id="ext")
with app.test_request_context(
"/text-to-audio",
@@ -294,8 +320,8 @@ class TestTextApi:
api = TextApi()
handler = unwrap(api.post)
app_model = SimpleNamespace(id="a1")
end_user = SimpleNamespace(id="end-user-1", external_user_id="ext")
app_model = _app()
end_user = _end_user(end_user_id="end-user-1", external_user_id="ext")
with app.test_request_context("/text-to-audio", method="POST", json={"text": "hello"}):
with pytest.raises(ProviderQuotaExceededError):
@@ -14,10 +14,29 @@ from werkzeug.exceptions import Unauthorized
import services.api_token_service as api_token_service_module
from models.engine import db
from models.enums import ApiTokenType
from models.model import ApiToken
from services.api_token_service import ApiTokenCache, CachedApiToken
def _api_token(
*,
token_id: str = "id-123",
app_id: str = "app-123",
tenant_id: str = "tenant-123",
token: str = "token-123",
) -> ApiToken:
"""Create a mapped API token for cache and single-flight behavior tests."""
return ApiToken(
id=token_id,
app_id=app_id,
tenant_id=tenant_id,
type=ApiTokenType.APP,
token=token,
last_used_at=None,
)
@pytest.fixture
def api_token_db() -> Iterator[Session]:
"""Provide the production database extension with an isolated SQLite token table."""
@@ -124,7 +143,7 @@ class TestFetchTokenWithSingleFlight:
def test_should_query_db_when_lock_acquired_and_cache_missed(self):
auth_token = "token-123"
scope = "app"
db_token = MagicMock()
db_token = _api_token()
lock = MagicMock()
lock.acquire.return_value = True
@@ -143,7 +162,7 @@ class TestFetchTokenWithSingleFlight:
def test_should_query_db_directly_when_lock_not_acquired(self):
auth_token = "token-123"
scope = "app"
db_token = MagicMock()
db_token = _api_token()
lock = MagicMock()
lock.acquire.return_value = False
@@ -184,7 +203,7 @@ class TestFetchTokenWithSingleFlight:
def test_should_fallback_to_db_query_when_lock_raises_exception(self):
auth_token = "token-123"
scope = "app"
db_token = MagicMock()
db_token = _api_token()
lock = MagicMock()
lock.acquire.side_effect = RuntimeError("redis lock error")
@@ -322,14 +341,7 @@ class TestApiTokenCacheCoreBranches:
@patch("services.api_token_service.redis_client")
def test_set_should_return_false_when_cache_write_raises_exception(self, mock_redis):
mock_redis.setex.side_effect = Exception("redis write failed")
api_token = MagicMock()
api_token.id = "id-123"
api_token.app_id = "app-123"
api_token.tenant_id = "tenant-123"
api_token.type = "app"
api_token.token = "token-123"
api_token.last_used_at = None
api_token.created_at = None
api_token = _api_token()
result = ApiTokenCache.set("token-123", "app", api_token)
assert result is False
@@ -10,9 +10,11 @@ from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session
import services.async_workflow_service as async_workflow_service_module
from models.enums import AppTriggerType, CreatorUserRole, WorkflowRunTriggeredFrom, WorkflowTriggerStatus
from models.model import App, AppMode
from models.account import Account
from models.enums import AppTriggerType, CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom, WorkflowTriggerStatus
from models.model import App, AppMode, EndUser
from models.trigger import WorkflowTriggerLog
from models.workflow import Workflow, WorkflowType
from services.async_workflow_service import AsyncWorkflowService
from services.errors.app import QuotaExceededError, WorkflowNotFoundError
from services.workflow.entities import AsyncTriggerResponse, TriggerData
@@ -94,6 +96,41 @@ class AsyncWorkflowServiceTestDataFactory:
trigger_log.created_at = created_at
return trigger_log
@staticmethod
def create_workflow(
*, workflow_id: str = "workflow-123", app_id: str = "app-123", tenant_id: str = "tenant-123"
) -> Workflow:
"""Create a mapped workflow for service-return and trigger-log tests."""
return Workflow(
id=workflow_id,
tenant_id=tenant_id,
app_id=app_id,
type=WorkflowType.WORKFLOW,
version="1",
graph="{}",
_features="{}",
created_by="account-123",
)
@staticmethod
def create_account(account_id: str = "account-123") -> Account:
"""Create a mapped account for role-discrimination tests."""
account = Account(name="Account", email=f"{account_id}@example.com")
account.id = account_id
return account
@staticmethod
def create_end_user(end_user_id: str = "end-user-123") -> EndUser:
"""Create a mapped end user for role-discrimination and retry tests."""
return EndUser(
id=end_user_id,
tenant_id="tenant-123",
app_id="app-123",
type=EndUserType.BROWSER,
name="End User",
session_id=f"session-{end_user_id}",
)
@pytest.mark.usefixtures("sqlite_session")
@pytest.mark.parametrize("sqlite_session", [(App, WorkflowTriggerLog)], indirect=True)
@@ -163,8 +200,7 @@ class TestAsyncWorkflowService:
sqlite_session.add(AsyncWorkflowServiceTestDataFactory.create_app())
sqlite_session.commit()
trigger_data = AsyncWorkflowServiceTestDataFactory.create_trigger_data()
workflow = MagicMock()
workflow.id = "workflow-123"
workflow = AsyncWorkflowServiceTestDataFactory.create_workflow()
mocks = async_workflow_trigger_mocks
mocks["dispatcher"].get_queue_name.return_value = queue_name
@@ -179,17 +215,12 @@ class TestAsyncWorkflowService:
quota_charge_mock = MagicMock()
mocks["quota_service"].reserve.return_value = quota_charge_mock
class DummyAccount:
def __init__(self, user_id: str):
self.id = user_id
user = AsyncWorkflowServiceTestDataFactory.create_account()
with patch.object(async_workflow_service_module, "Account", DummyAccount):
user = DummyAccount("account-123")
# Act
result = AsyncWorkflowService.trigger_workflow_async(
session=sqlite_session, user=user, trigger_data=trigger_data
)
# Act
result = AsyncWorkflowService.trigger_workflow_async(
session=sqlite_session, user=user, trigger_data=trigger_data
)
# Assert
assert isinstance(result, AsyncTriggerResponse)
@@ -233,8 +264,7 @@ class TestAsyncWorkflowService:
sqlite_session.add(AsyncWorkflowServiceTestDataFactory.create_app())
sqlite_session.commit()
trigger_data = AsyncWorkflowServiceTestDataFactory.create_trigger_data()
workflow = MagicMock()
workflow.id = "workflow-123"
workflow = AsyncWorkflowServiceTestDataFactory.create_workflow()
mocks = async_workflow_trigger_mocks
mocks["dispatcher"].get_queue_name.return_value = QueuePriority.SANDBOX
@@ -243,7 +273,7 @@ class TestAsyncWorkflowService:
task_result = MagicMock(id="task-123")
mocks["sandbox_task"].delay.return_value = task_result
user = SimpleNamespace(id="end-user-123")
user = AsyncWorkflowServiceTestDataFactory.create_end_user()
# Act
response = AsyncWorkflowService.trigger_workflow_async(
@@ -269,7 +299,7 @@ class TestAsyncWorkflowService:
with pytest.raises(WorkflowNotFoundError, match="App not found: missing-app"):
AsyncWorkflowService.trigger_workflow_async(
session=sqlite_session,
user=SimpleNamespace(id="user-123"),
user=AsyncWorkflowServiceTestDataFactory.create_end_user("user-123"),
trigger_data=trigger_data,
)
@@ -284,8 +314,7 @@ class TestAsyncWorkflowService:
sqlite_session.add(AsyncWorkflowServiceTestDataFactory.create_app())
sqlite_session.commit()
trigger_data = AsyncWorkflowServiceTestDataFactory.create_trigger_data()
workflow = MagicMock()
workflow.id = "workflow-123"
workflow = AsyncWorkflowServiceTestDataFactory.create_workflow()
mocks = async_workflow_trigger_mocks
mocks["dispatcher"].get_queue_name.return_value = QueuePriority.TEAM
@@ -301,7 +330,7 @@ class TestAsyncWorkflowService:
with pytest.raises(QuotaExceededError) as exc_info:
AsyncWorkflowService.trigger_workflow_async(
session=sqlite_session,
user=SimpleNamespace(id="user-123"),
user=AsyncWorkflowServiceTestDataFactory.create_end_user("user-123"),
trigger_data=trigger_data,
)
@@ -328,7 +357,7 @@ class TestAsyncWorkflowService:
with pytest.raises(ValueError, match="Trigger log not found: missing-log"):
AsyncWorkflowService.reinvoke_trigger(
session=sqlite_session,
user=SimpleNamespace(id="user-123"),
user=AsyncWorkflowServiceTestDataFactory.create_end_user("user-123"),
workflow_trigger_log_id="missing-log",
)
@@ -354,7 +383,7 @@ class TestAsyncWorkflowService:
return_value=expected_response,
) as mock_trigger_workflow_async,
):
user = SimpleNamespace(id="user-123")
user = AsyncWorkflowServiceTestDataFactory.create_end_user("user-123")
# Act
response = AsyncWorkflowService.reinvoke_trigger(
@@ -497,8 +526,8 @@ class TestAsyncWorkflowServiceGetWorkflow:
"""Test _get_workflow returns published workflow by id when provided."""
# Arrange
workflow_service = MagicMock()
app_model = MagicMock()
workflow = MagicMock()
app_model = AsyncWorkflowServiceTestDataFactory.create_app()
workflow = AsyncWorkflowServiceTestDataFactory.create_workflow()
workflow_service.get_published_workflow_by_id.return_value = workflow
# Act
@@ -517,7 +546,7 @@ class TestAsyncWorkflowServiceGetWorkflow:
"""Test _get_workflow raises WorkflowNotFoundError for unknown workflow id."""
# Arrange
workflow_service = MagicMock()
app_model = MagicMock()
app_model = AsyncWorkflowServiceTestDataFactory.create_app()
workflow_service.get_published_workflow_by_id.return_value = None
# Act / Assert
@@ -530,9 +559,8 @@ class TestAsyncWorkflowServiceGetWorkflow:
"""Test _get_workflow returns default published workflow when no id is provided."""
# Arrange
workflow_service = MagicMock()
app_model = MagicMock()
app_model.id = "app-123"
workflow = MagicMock()
app_model = AsyncWorkflowServiceTestDataFactory.create_app()
workflow = AsyncWorkflowServiceTestDataFactory.create_workflow()
workflow_service.get_published_workflow.return_value = workflow
# Act
@@ -547,8 +575,7 @@ class TestAsyncWorkflowServiceGetWorkflow:
"""Test _get_workflow raises WorkflowNotFoundError when app has no published workflow."""
# Arrange
workflow_service = MagicMock()
app_model = MagicMock()
app_model.id = "app-123"
app_model = AsyncWorkflowServiceTestDataFactory.create_app()
workflow_service.get_published_workflow.return_value = None
# Act / Assert
@@ -53,9 +53,11 @@ Tests available voice retrieval:
- text_to_speech: Enables TTS functionality
"""
import json
from decimal import Decimal
from typing import Any
from unittest.mock import MagicMock, Mock, create_autospec, patch
from unittest.mock import MagicMock, Mock, patch
from uuid import uuid4
import pytest
from sqlalchemy.orm import Session
@@ -64,7 +66,7 @@ from werkzeug.datastructures import FileStorage
from models.agent_config_entities import AgentSoulConfig
from models.enums import ConversationFromSource, MessageStatus
from models.model import App, AppMode, AppModelConfig, Message
from models.workflow import Workflow
from models.workflow import Workflow, WorkflowType
from services.app_ref_service import AppRef, MessageRef
from services.audio_service import AudioService
from services.errors.audio import (
@@ -113,15 +115,18 @@ class AudioServiceTestDataFactory:
audio-related operations.
"""
@staticmethod
def __init__(self, session: Session) -> None:
self.session = session
def create_app_mock(
app_id: str = "app-123",
self,
app_id: str = APP_ID,
mode: AppMode = AppMode.CHAT,
tenant_id: str = "tenant-123",
tenant_id: str = TENANT_ID,
**kwargs,
) -> Mock:
) -> App:
"""
Create a mock App object.
Create and persist an App model.
Args:
app_id: Unique identifier for the app
@@ -130,46 +135,65 @@ class AudioServiceTestDataFactory:
**kwargs: Additional attributes to set on the mock
Returns:
Mock App object with specified attributes
Persisted App model with specified attributes
"""
app = create_autospec(App, instance=True)
app.id = app_id
app.mode = mode
app.tenant_id = tenant_id
app.workflow = kwargs.get("workflow")
app.app_model_config = kwargs.get("app_model_config")
app.workflow_with_session.return_value = app.workflow
app.app_model_config_with_session.return_value = app.app_model_config
workflow = kwargs.pop("workflow", None)
app_model_config = kwargs.pop("app_model_config", None)
app = App(
id=app_id,
tenant_id=tenant_id,
name="Audio test app",
description="",
mode=mode,
icon_type=None,
icon=None,
icon_background=None,
enable_site=False,
enable_api=False,
workflow_id=workflow.id if workflow else None,
app_model_config_id=app_model_config.id if app_model_config else None,
)
for key, value in kwargs.items():
setattr(app, key, value)
self.session.add(app)
self.session.commit()
return app
@staticmethod
def create_workflow_mock(features_dict: dict[str, Any] | None = None, **kwargs) -> Mock:
def create_workflow_mock(self, features_dict: dict[str, Any] | None = None, **kwargs) -> Workflow:
"""
Create a mock Workflow object.
Create and persist a Workflow model.
Args:
features_dict: Dictionary of workflow features
**kwargs: Additional attributes to set on the mock
Returns:
Mock Workflow object with specified attributes
Persisted Workflow model with specified attributes
"""
workflow = create_autospec(Workflow, instance=True)
workflow.features_dict = features_dict or {}
workflow = Workflow(
id=kwargs.pop("id", str(uuid4())),
tenant_id=kwargs.pop("tenant_id", TENANT_ID),
app_id=kwargs.pop("app_id", APP_ID),
type=kwargs.pop("type", WorkflowType.CHAT),
version=kwargs.pop("version", Workflow.VERSION_DRAFT),
graph=kwargs.pop("graph", "{}"),
_features=json.dumps(features_dict or {}),
created_by=kwargs.pop("created_by", ACCOUNT_ID),
)
for key, value in kwargs.items():
setattr(workflow, key, value)
self.session.add(workflow)
self.session.commit()
return workflow
@staticmethod
def create_app_model_config_mock(
self,
speech_to_text_dict: dict[str, Any] | None = None,
text_to_speech_dict: dict[str, Any] | None = None,
**kwargs,
) -> Mock:
) -> AppModelConfig:
"""
Create a mock AppModelConfig object.
Create and persist an AppModelConfig model.
Args:
speech_to_text_dict: Speech-to-text configuration
@@ -177,13 +201,17 @@ class AudioServiceTestDataFactory:
**kwargs: Additional attributes to set on the mock
Returns:
Mock AppModelConfig object with specified attributes
Persisted AppModelConfig model with specified attributes
"""
config = create_autospec(AppModelConfig, instance=True)
config.speech_to_text_dict = speech_to_text_dict or {"enabled": False}
config.text_to_speech_dict = text_to_speech_dict or {"enabled": False}
config = AppModelConfig(
app_id=kwargs.pop("app_id", APP_ID),
speech_to_text=json.dumps(speech_to_text_dict or {"enabled": False}),
text_to_speech=json.dumps(text_to_speech_dict or {"enabled": False}),
)
for key, value in kwargs.items():
setattr(config, key, value)
self.session.add(config)
self.session.commit()
return config
@staticmethod
@@ -216,9 +244,9 @@ class AudioServiceTestDataFactory:
@pytest.fixture
def factory():
def factory(sqlite_session: Session) -> AudioServiceTestDataFactory:
"""Provide the test data factory to all tests."""
return AudioServiceTestDataFactory
return AudioServiceTestDataFactory(sqlite_session)
class TestAudioServiceASR:
@@ -385,7 +413,6 @@ class TestAudioServiceASR:
self, mock_model_manager_class, factory: AudioServiceTestDataFactory
):
app_model_config = factory.create_app_model_config_mock(speech_to_text_dict={"enabled": True})
app_model_config.to_dict.return_value = {"speech_to_text": {"enabled": True}}
app = factory.create_app_mock(mode=AppMode.AGENT, app_model_config=app_model_config)
file = factory.create_file_storage_mock()
mock_model_instance = MagicMock()
@@ -403,7 +430,6 @@ class TestAudioServiceASR:
def test_transcript_agent_asr_soul_disabled_overrides_legacy_feature(self, factory: AudioServiceTestDataFactory):
app_model_config = factory.create_app_model_config_mock(speech_to_text_dict={"enabled": True})
app_model_config.to_dict.return_value = {"speech_to_text": {"enabled": True}}
app = factory.create_app_mock(mode=AppMode.AGENT, app_model_config=app_model_config)
file = factory.create_file_storage_mock()
agent_soul = AgentSoulConfig.model_validate({"app_features": {"speech_to_text": {"enabled": False}}})
@@ -38,6 +38,7 @@ from models.model import (
Message,
MessageFeedback,
)
from models.workflow import Workflow, WorkflowType
from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchemyExecutionExtraContentRepository
from services.errors.message import (
FirstMessageNotExistsError,
@@ -111,6 +112,19 @@ class MessageServiceTestDataFactory:
account.id = user_id
return account
@staticmethod
def create_workflow(*, features: dict[str, object] | None = None) -> Workflow:
return Workflow(
id="workflow-123",
tenant_id="tenant-123",
app_id="app-123",
type=WorkflowType.CHAT,
version="1",
graph="{}",
_features=json.dumps(features or {}),
created_by="account-123",
)
@staticmethod
def create_conversation(
conversation_id: str = "conv-001",
@@ -712,8 +726,7 @@ class TestMessageServiceSuggestedQuestions:
monkeypatch: pytest.MonkeyPatch,
conversation: Conversation,
) -> tuple[MagicMock, MagicMock, MagicMock]:
message = MagicMock()
message.conversation_id = conversation.id
message = MessageServiceTestDataFactory.create_message(message_id="msg-123", conversation_id=conversation.id)
monkeypatch.setattr(service_module.MessageService, "get_message", MagicMock(return_value=message))
monkeypatch.setattr(
service_module.ConversationService, "get_conversation", MagicMock(return_value=conversation)
@@ -747,8 +760,7 @@ class TestMessageServiceSuggestedQuestions:
) -> None:
conversation = factory.create_conversation()
_, _, llm_generator = self._chat_boundaries(monkeypatch, conversation)
workflow = MagicMock()
workflow.features_dict = {"suggested_questions_after_answer": {"enabled": True}}
workflow = factory.create_workflow(features={"suggested_questions_after_answer": {"enabled": True}})
workflow_service = MagicMock()
workflow_service.return_value.get_published_workflow.return_value = workflow
monkeypatch.setattr(service_module, "WorkflowService", workflow_service)
@@ -1103,7 +1115,7 @@ class TestMessageServiceSuggestedQuestions:
) -> None:
conversation = factory.create_conversation()
self._chat_boundaries(monkeypatch, conversation)
workflow = MagicMock()
workflow = factory.create_workflow()
workflow_service = MagicMock()
workflow_service.return_value.get_published_workflow.return_value = workflow
monkeypatch.setattr(service_module, "WorkflowService", workflow_service)
@@ -16,7 +16,7 @@ import services.vector_service as vector_service_module
from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType
from extensions.storage.storage_type import StorageType
from models import UploadFile
from models.dataset import ChildChunk, DatasetProcessRule, SegmentAttachmentBinding
from models.dataset import ChildChunk, Dataset, DatasetProcessRule, DocumentSegment, SegmentAttachmentBinding
from models.dataset import Document as DatasetDocument
from models.enums import CreatorUserRole, DataSourceType, DocumentCreatedFrom, ProcessRuleMode
from services.vector_service import VectorService
@@ -42,16 +42,18 @@ def _make_dataset(
is_multimodal: bool = False,
embedding_model_provider: str | None = "openai",
embedding_model: str = "text-embedding",
) -> MagicMock:
dataset = MagicMock(name="dataset")
dataset.id = dataset_id
dataset.tenant_id = tenant_id
dataset.doc_form = doc_form
dataset.indexing_technique = indexing_technique
dataset.is_multimodal = is_multimodal
dataset.embedding_model_provider = embedding_model_provider
dataset.embedding_model = embedding_model
dataset.get_doc_form.return_value = doc_form
) -> Dataset:
dataset = Dataset(
id=dataset_id,
tenant_id=tenant_id,
name="Dataset",
created_by="account-1",
indexing_technique=indexing_technique,
chunk_structure=doc_form,
is_multimodal=is_multimodal,
embedding_model_provider=embedding_model_provider,
embedding_model=embedding_model,
)
return dataset
@@ -64,24 +66,49 @@ def _make_segment(
content: str = "hello",
index_node_id: str = "node-1",
index_node_hash: str = "hash-1",
session: Session | None = None,
attachments: list[dict[str, str]] | None = None,
) -> MagicMock:
segment = MagicMock(name="segment")
) -> DocumentSegment:
segment = DocumentSegment(
tenant_id=tenant_id,
dataset_id=dataset_id,
document_id=document_id,
position=1,
content=content,
word_count=len(content),
tokens=len(content),
created_by="account-1",
index_node_id=index_node_id,
index_node_hash=index_node_hash,
)
segment.id = segment_id
segment.tenant_id = tenant_id
segment.dataset_id = dataset_id
segment.document_id = document_id
segment.content = content
segment.index_node_id = index_node_id
segment.index_node_hash = index_node_hash
segment.attachments = attachments or []
segment.get_attachments.return_value = attachments or []
if attachments:
assert session is not None
for attachment in attachments:
upload_file = _upload_file(
file_id=attachment["id"],
name=attachment.get("name", f"{attachment['id']}.png"),
tenant_id=tenant_id,
)
session.add_all(
[
upload_file,
SegmentAttachmentBinding(
tenant_id=tenant_id,
dataset_id=dataset_id,
document_id=document_id,
segment_id=segment_id,
attachment_id=upload_file.id,
),
]
)
session.flush()
return segment
def _upload_file(*, file_id: str = "file-1", name: str = "img.png") -> UploadFile:
def _upload_file(*, file_id: str = "file-1", name: str = "img.png", tenant_id: str = "tenant-1") -> UploadFile:
upload_file = UploadFile(
tenant_id="tenant-1",
tenant_id=tenant_id,
storage_type=StorageType.LOCAL,
key=f"uploads/{file_id}",
name=name,
@@ -97,6 +124,30 @@ def _upload_file(*, file_id: str = "file-1", name: str = "img.png") -> UploadFil
return upload_file
def _make_child_chunk(
*,
index_node_id: str,
content: str = "child",
index_node_hash: str = "hash",
tenant_id: str = "tenant-1",
dataset_id: str = "dataset-1",
document_id: str = "doc-1",
segment_id: str = "seg-1",
) -> ChildChunk:
return ChildChunk(
tenant_id=tenant_id,
dataset_id=dataset_id,
document_id=document_id,
segment_id=segment_id,
position=1,
content=content,
word_count=len(content),
created_by="account-1",
index_node_id=index_node_id,
index_node_hash=index_node_hash,
)
def test_create_segments_vector_regular_indexing_loads_documents_and_keywords(
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
) -> None:
@@ -126,10 +177,11 @@ def test_create_segments_vector_regular_indexing_loads_multimodal_documents(
) -> None:
dataset = _make_dataset(is_multimodal=True)
segment = _make_segment(
session=sqlite_session,
attachments=[
{"id": "img-1", "name": "a.png"},
{"id": "img-2", "name": "b.png"},
]
],
)
index_processor = MagicMock(name="index_processor")
@@ -152,7 +204,7 @@ def test_create_segments_vector_regular_indexing_loads_multimodal_documents(
assert second_args[1] == []
assert len(second_args[2]) == 2
assert second_kwargs["with_keywords"] is False
segment.get_attachments.assert_called_once_with(session=sqlite_session)
assert {document.page_content for document in second_args[2]} == {"a.png", "b.png"}
def test_create_segments_vector_with_no_segments_does_not_load(
@@ -171,7 +223,7 @@ def test_create_segments_vector_with_no_segments_does_not_load(
def _persist_parent_child_rows(
session: Session,
*,
segment: MagicMock,
segment: DocumentSegment,
include_document: bool = True,
include_rule: bool = True,
) -> tuple[DatasetDocument | None, DatasetProcessRule | None]:
@@ -415,13 +467,24 @@ def test_generate_child_chunks_regenerate_cleans_then_saves_children(
dataset = _make_dataset(doc_form=IndexStructureType.PARAGRAPH_INDEX, tenant_id="tenant-1", dataset_id="dataset-1")
segment = _make_segment(segment_id="seg-1")
dataset_document = MagicMock()
dataset_document.id = segment.document_id
dataset_document.doc_language = "en"
dataset_document.created_by = "user-1"
processing_rule = MagicMock()
processing_rule.to_dict.return_value = {"rules": {}}
dataset_document = DatasetDocument(
id=segment.document_id,
tenant_id=segment.tenant_id,
dataset_id=segment.dataset_id,
position=1,
data_source_type=DataSourceType.UPLOAD_FILE,
batch="batch-1",
name="Document",
created_from=DocumentCreatedFrom.API,
created_by="user-1",
doc_language="en",
)
processing_rule = DatasetProcessRule(
dataset_id=segment.dataset_id,
mode=ProcessRuleMode.HIERARCHICAL,
rules="{}",
created_by="user-1",
)
child1 = _ChildDocStub(page_content="c1", metadata={"doc_id": "c1-id", "doc_hash": "c1-h"})
child2 = _ChildDocStub(page_content="c2", metadata={"doc_id": "c2-id", "doc_hash": "c2-h"})
@@ -456,12 +519,24 @@ def test_generate_child_chunks_flushes_even_when_no_children(
) -> None:
dataset = _make_dataset(doc_form=IndexStructureType.PARAGRAPH_INDEX)
segment = _make_segment()
dataset_document = MagicMock()
dataset_document.doc_language = "en"
dataset_document.created_by = "user-1"
processing_rule = MagicMock()
processing_rule.to_dict.return_value = {"rules": {}}
dataset_document = DatasetDocument(
id=segment.document_id,
tenant_id=segment.tenant_id,
dataset_id=segment.dataset_id,
position=1,
data_source_type=DataSourceType.UPLOAD_FILE,
batch="batch-1",
name="Document",
created_from=DocumentCreatedFrom.API,
created_by="user-1",
doc_language="en",
)
processing_rule = DatasetProcessRule(
dataset_id=segment.dataset_id,
mode=ProcessRuleMode.HIERARCHICAL,
rules="{}",
created_by="user-1",
)
index_processor = MagicMock()
index_processor.transform.return_value = [_ParentDocStub(children=[])]
@@ -487,12 +562,7 @@ def test_create_child_chunk_vector_high_quality_adds_texts(
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
) -> None:
dataset = _make_dataset(indexing_technique=IndexTechniqueType.HIGH_QUALITY)
child_chunk = MagicMock()
child_chunk.content = "child"
child_chunk.index_node_id = "id"
child_chunk.index_node_hash = "h"
child_chunk.document_id = "doc-1"
child_chunk.dataset_id = "dataset-1"
child_chunk = _make_child_chunk(index_node_id="id", index_node_hash="h")
vector_instance = MagicMock()
vector_cls = MagicMock(return_value=vector_instance)
@@ -508,12 +578,7 @@ def test_create_child_chunk_vector_economy_noop(monkeypatch: pytest.MonkeyPatch,
vector_cls = MagicMock()
monkeypatch.setattr(vector_service_module, "Vector", vector_cls)
child_chunk = MagicMock()
child_chunk.content = "child"
child_chunk.index_node_id = "id"
child_chunk.index_node_hash = "h"
child_chunk.document_id = "doc-1"
child_chunk.dataset_id = "dataset-1"
child_chunk = _make_child_chunk(index_node_id="id", index_node_hash="h")
VectorService.create_child_chunk_vector(child_chunk, dataset, session=sqlite_session)
vector_cls.assert_not_called()
@@ -524,22 +589,13 @@ def test_update_child_chunk_vector_high_quality_updates_vector(
) -> None:
dataset = _make_dataset(indexing_technique=IndexTechniqueType.HIGH_QUALITY)
new_chunk = MagicMock()
new_chunk.content = "n"
new_chunk.index_node_id = "nid"
new_chunk.index_node_hash = "nh"
new_chunk.document_id = "d"
new_chunk.dataset_id = "ds"
upd_chunk = MagicMock()
upd_chunk.content = "u"
upd_chunk.index_node_id = "uid"
upd_chunk.index_node_hash = "uh"
upd_chunk.document_id = "d"
upd_chunk.dataset_id = "ds"
del_chunk = MagicMock()
del_chunk.index_node_id = "did"
new_chunk = _make_child_chunk(
content="n", index_node_id="nid", index_node_hash="nh", document_id="d", dataset_id="ds"
)
upd_chunk = _make_child_chunk(
content="u", index_node_id="uid", index_node_hash="uh", document_id="d", dataset_id="ds"
)
del_chunk = _make_child_chunk(index_node_id="did")
vector_instance = MagicMock()
vector_cls = MagicMock(return_value=vector_instance)
@@ -564,8 +620,7 @@ def test_update_child_chunk_vector_economy_noop(monkeypatch: pytest.MonkeyPatch,
def test_delete_child_chunk_vector_deletes_by_id(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
dataset = _make_dataset()
child_chunk = MagicMock()
child_chunk.index_node_id = "cid"
child_chunk = _make_child_chunk(index_node_id="cid")
vector_instance = MagicMock()
vector_cls = MagicMock(return_value=vector_instance)
@@ -585,7 +640,7 @@ def test_update_multimodel_vector_returns_when_not_high_quality(
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
) -> None:
dataset = _make_dataset(indexing_technique=IndexTechniqueType.ECONOMY, is_multimodal=True)
segment = _make_segment(tenant_id="t", attachments=[{"id": "a"}])
segment = _make_segment(tenant_id="t")
vector_cls = MagicMock()
monkeypatch.setattr(vector_service_module, "Vector", vector_cls)
@@ -601,7 +656,7 @@ def test_update_multimodel_vector_returns_when_no_actual_change(
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
) -> None:
dataset = _make_dataset(indexing_technique=IndexTechniqueType.HIGH_QUALITY, is_multimodal=True)
segment = _make_segment(tenant_id="t", attachments=[{"id": "a"}, {"id": "b"}])
segment = _make_segment(tenant_id="t", session=sqlite_session, attachments=[{"id": "a"}, {"id": "b"}])
vector_cls = MagicMock()
monkeypatch.setattr(vector_service_module, "Vector", vector_cls)
@@ -610,7 +665,7 @@ def test_update_multimodel_vector_returns_when_no_actual_change(
session=sqlite_session, segment=segment, attachment_ids=["b", "a"], dataset=dataset
)
vector_cls.assert_not_called()
assert not sqlite_session.in_transaction()
assert sqlite_session.in_transaction()
def test_update_multimodel_vector_deletes_bindings_and_commits_on_empty_new_ids(
@@ -618,23 +673,14 @@ def test_update_multimodel_vector_deletes_bindings_and_commits_on_empty_new_ids(
sqlite_session: Session,
) -> None:
dataset = _make_dataset(indexing_technique=IndexTechniqueType.HIGH_QUALITY, is_multimodal=True)
segment = _make_segment(tenant_id="tenant-1", attachments=[{"id": "old-1"}, {"id": "old-2"}])
segment = _make_segment(
tenant_id="tenant-1",
session=sqlite_session,
attachments=[{"id": "old-1"}, {"id": "old-2"}],
)
vector_instance = MagicMock(name="vector_instance")
vector_cls = MagicMock(return_value=vector_instance)
sqlite_session.add_all(
[
SegmentAttachmentBinding(
tenant_id="tenant-1",
dataset_id="dataset-1",
document_id="doc-1",
segment_id="seg-1",
attachment_id=attachment_id,
)
for attachment_id in ("old-1", "old-2")
]
)
sqlite_session.flush()
monkeypatch.setattr(vector_service_module, "Vector", vector_cls)
@@ -650,7 +696,7 @@ def test_update_multimodel_vector_flushes_when_no_upload_files_found(
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
) -> None:
dataset = _make_dataset(indexing_technique=IndexTechniqueType.HIGH_QUALITY, is_multimodal=True)
segment = _make_segment(tenant_id="tenant-1", attachments=[{"id": "old-1"}])
segment = _make_segment(tenant_id="tenant-1", session=sqlite_session, attachments=[{"id": "old-1"}])
vector_instance = MagicMock()
monkeypatch.setattr(vector_service_module, "Vector", MagicMock(return_value=vector_instance))
@@ -668,7 +714,12 @@ def test_update_multimodel_vector_adds_bindings_and_vectors_and_skips_missing_up
sqlite_session: Session,
) -> None:
dataset = _make_dataset(indexing_technique=IndexTechniqueType.HIGH_QUALITY, is_multimodal=True)
segment = _make_segment(segment_id="seg-1", tenant_id="tenant-1", attachments=[{"id": "old-1"}])
segment = _make_segment(
segment_id="seg-1",
tenant_id="tenant-1",
session=sqlite_session,
attachments=[{"id": "old-1"}],
)
vector_instance = MagicMock()
monkeypatch.setattr(vector_service_module, "Vector", MagicMock(return_value=vector_instance))
@@ -696,7 +747,7 @@ def test_update_multimodel_vector_updates_bindings_without_multimodal_vector_ops
sqlite_session: Session,
) -> None:
dataset = _make_dataset(indexing_technique=IndexTechniqueType.HIGH_QUALITY, is_multimodal=False)
segment = _make_segment(tenant_id="tenant-1", attachments=[{"id": "old-1"}])
segment = _make_segment(tenant_id="tenant-1", session=sqlite_session, attachments=[{"id": "old-1"}])
vector_instance = MagicMock()
monkeypatch.setattr(vector_service_module, "Vector", MagicMock(return_value=vector_instance))
@@ -719,7 +770,12 @@ def test_update_multimodel_vector_rolls_back_and_reraises_on_error(
sqlite_session: Session,
) -> None:
dataset = _make_dataset(indexing_technique=IndexTechniqueType.HIGH_QUALITY, is_multimodal=True)
segment = _make_segment(segment_id="seg-1", tenant_id="tenant-1", attachments=[{"id": "old-1"}])
segment = _make_segment(
segment_id="seg-1",
tenant_id="tenant-1",
session=sqlite_session,
attachments=[{"id": "old-1"}],
)
vector_instance = MagicMock()
monkeypatch.setattr(vector_service_module, "Vector", MagicMock(return_value=vector_instance))
@@ -730,10 +786,11 @@ def test_update_multimodel_vector_rolls_back_and_reraises_on_error(
monkeypatch.setattr(sqlite_session, "flush", MagicMock(side_effect=RuntimeError("boom")))
with caplog.at_level(logging.ERROR, logger="services.vector_service"):
with pytest.raises(RuntimeError, match="boom"):
VectorService.update_multimodel_vector(
session=sqlite_session, segment=segment, attachment_ids=["file-1"], dataset=dataset
)
with sqlite_session.no_autoflush:
with pytest.raises(RuntimeError, match="boom"):
VectorService.update_multimodel_vector(
session=sqlite_session, segment=segment, attachment_ids=["file-1"], dataset=dataset
)
assert any(r.levelno >= logging.ERROR for r in caplog.records)
assert rollback_events == ["rollback"]