test: 补充知识处理、检索与 Agent 回归用例
覆盖切块、向量检索、工具调用和历史预算;模型相关用例使用桩。
This commit is contained in:
1 parent
2dd062787d
commit
d9fe727c3f
123 files changed
+29005
No files matched your search
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
A source's own status never depends on telling someone about it. Seen in
|
||||
production: a Shopify install queues the shop's site with no user, so the
|
||||
"processed" notification hit the NOT NULL user_id constraint, and the failure
|
||||
handler then failed the same way and lost the FAILED status too.
|
||||
|
||||
Run against a real database: the bug was a database constraint.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.models.knowledge_queue import KnowledgeQueue, QueueStatus
|
||||
from app.models.notification import Notification, NotificationType
|
||||
from app.workers import knowledge_processor
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def queue_item(db, test_organization):
|
||||
def make(user_id=None, source="https://shop.example"):
|
||||
item = KnowledgeQueue(organization_id=test_organization.id, user_id=user_id, source=source,
|
||||
source_type="website", status=QueueStatus.PENDING)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
return item.id
|
||||
return make
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sessions(db):
|
||||
"""New sessions on the fixture's database, as the worker opens its own."""
|
||||
return sessionmaker(bind=db.get_bind())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def worker(monkeypatch, sessions):
|
||||
"""The worker on the test database, with crawling and the side jobs stubbed."""
|
||||
manager = AsyncMock()
|
||||
monkeypatch.setattr(knowledge_processor, "SessionLocal", sessions)
|
||||
monkeypatch.setattr(knowledge_processor, "KnowledgeManager", lambda **kw: manager)
|
||||
monkeypatch.setattr(knowledge_processor, "_sync_vector_agent_links", lambda db, item: None)
|
||||
push = AsyncMock()
|
||||
monkeypatch.setattr(knowledge_processor, "send_fcm_notification", push)
|
||||
return manager, push
|
||||
|
||||
|
||||
def state(sessions, item_id):
|
||||
with sessions() as db:
|
||||
item = db.get(KnowledgeQueue, item_id)
|
||||
notifications = db.query(Notification).filter(
|
||||
Notification.notification_metadata.isnot(None)).all()
|
||||
return item.status, item.error, [n for n in notifications if n.notification_metadata.get("queue_id") == item_id]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_source_with_no_user_completes_without_a_notification(worker, sessions, queue_item):
|
||||
manager, push = worker
|
||||
item_id = queue_item(user_id=None) # what a Shopify install queues
|
||||
|
||||
await knowledge_processor.process_queue_item(item_id)
|
||||
|
||||
status, error, notifications = state(sessions, item_id)
|
||||
assert (status, error, notifications) == (QueueStatus.COMPLETED, None, [])
|
||||
push.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_source_with_no_user_that_fails_keeps_its_failed_status(worker, sessions, queue_item):
|
||||
manager, push = worker
|
||||
manager.process_knowledge.side_effect = RuntimeError("https://shop.example did not respond in time.")
|
||||
item_id = queue_item(user_id=None)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await knowledge_processor.process_queue_item(item_id)
|
||||
|
||||
status, error, notifications = state(sessions, item_id)
|
||||
assert status == QueueStatus.FAILED
|
||||
assert "did not respond in time" in error
|
||||
assert notifications == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_person_who_added_it_is_told_it_is_ready(worker, sessions, queue_item, test_user):
|
||||
manager, push = worker
|
||||
item_id = queue_item(user_id=test_user.id)
|
||||
|
||||
await knowledge_processor.process_queue_item(item_id)
|
||||
|
||||
status, _, notifications = state(sessions, item_id)
|
||||
assert status == QueueStatus.COMPLETED
|
||||
assert [(n.user_id, n.type) for n in notifications] == [(test_user.id, NotificationType.KNOWLEDGE_PROCESSED)]
|
||||
push.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_person_who_added_it_is_told_it_failed(worker, sessions, queue_item, test_user):
|
||||
manager, push = worker
|
||||
manager.process_knowledge.side_effect = RuntimeError("could not be found")
|
||||
item_id = queue_item(user_id=test_user.id)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await knowledge_processor.process_queue_item(item_id)
|
||||
|
||||
status, error, notifications = state(sessions, item_id)
|
||||
assert (status, error) == (QueueStatus.FAILED, "could not be found")
|
||||
assert [n.type for n in notifications] == [NotificationType.KNOWLEDGE_FAILED]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_notification_that_cannot_be_saved_does_not_fail_the_source(worker, sessions, queue_item, test_user, monkeypatch):
|
||||
manager, push = worker
|
||||
item_id = queue_item(user_id=test_user.id)
|
||||
real_init = Notification.__init__
|
||||
|
||||
def broken(self, **kwargs):
|
||||
real_init(self, **{**kwargs, "user_id": None}) # violates NOT NULL on flush
|
||||
monkeypatch.setattr(Notification, "__init__", broken)
|
||||
|
||||
await knowledge_processor.process_queue_item(item_id)
|
||||
|
||||
status, error, notifications = state(sessions, item_id)
|
||||
assert (status, error, notifications) == (QueueStatus.COMPLETED, None, [])
|
||||
push.assert_not_called()
|
||||
|
||||
|
||||
def test_the_service_starts_firebase_before_its_loops():
|
||||
order = []
|
||||
with patch("app.services.firebase.initialize_firebase", side_effect=lambda: order.append("firebase")), \
|
||||
patch.object(knowledge_processor.asyncio, "run", side_effect=lambda coro: (coro.close(), order.append("loops"))):
|
||||
knowledge_processor.run_service()
|
||||
assert order == ["firebase", "loops"]
|
||||
|
||||
|
||||
def test_the_service_still_runs_without_firebase():
|
||||
started = []
|
||||
with patch("app.services.firebase.initialize_firebase", side_effect=ValueError("no credentials")), \
|
||||
patch.object(knowledge_processor.asyncio, "run", side_effect=lambda coro: (coro.close(), started.append(True))):
|
||||
knowledge_processor.run_service()
|
||||
assert started == [True]
|
||||
Reference in new issue
Block a user