test: 补充知识处理、检索与 Agent 回归用例

覆盖切块、向量检索、工具调用和历史预算;模型相关用例使用桩。
This commit is contained in:
WangShen007 committed 2026-10-04 20:55:00 +08:00
1 parent 362d79a5c2
commit f4d8019829
123 files changed
+29005

No files matched your search

@@ -0,0 +1,742 @@
"""
Copyright 2024-2026 ChatterMate
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.
"""
import pytest
from uuid import uuid4
from unittest.mock import Mock, MagicMock, patch, PropertyMock
from datetime import datetime
from app.repositories.widget_app import WidgetAppRepository
from app.core.security import widget_api_key_lookup
from app.models.user import User
from app.models.widget_app import WidgetApp
@pytest.fixture
def mock_db():
"""Create a mock database session."""
db = MagicMock()
db.query = MagicMock()
db.add = MagicMock()
db.commit = MagicMock()
db.refresh = MagicMock()
db.delete = MagicMock()
db.rollback = MagicMock()
return db
@pytest.fixture
def widget_app_repo(mock_db) -> WidgetAppRepository:
"""Create a WidgetAppRepository instance with mock db."""
return WidgetAppRepository(mock_db)
@pytest.fixture
def org_id():
"""Generate a random organization ID."""
return uuid4()
@pytest.fixture
def user_id():
"""Generate a random user ID."""
return uuid4()
@pytest.fixture
def mock_widget_app(org_id, user_id):
"""Create a mock WidgetApp object."""
app = Mock(spec=WidgetApp)
app.id = uuid4()
app.name = "Test App"
app.description = "Test description"
app.organization_id = org_id
app.created_by = user_id
app.is_active = True
app.api_key_hash = "$2b$12$mockedhashvalue"
app.created_at = datetime.utcnow()
app.updated_at = datetime.utcnow()
return app
class TestWidgetAppCreation:
"""Tests for widget app creation."""
@patch('app.repositories.widget_app.generate_widget_api_key')
@patch('app.repositories.widget_app.hash_widget_api_key')
def test_create_app_generates_valid_key(
self, mock_hash, mock_generate, widget_app_repo, mock_db, org_id, user_id
):
"""Test that create_app generates a valid API key."""
mock_generate.return_value = "wak_test_key_12345678901234567890123456789012345"
mock_hash.return_value = "$2b$12$hashedkey"
# Mock refresh to set the id on the app
def mock_refresh(app):
app.id = uuid4()
mock_db.refresh.side_effect = mock_refresh
app, plain_key = widget_app_repo.create_app(
organization_id=org_id,
created_by=user_id,
name="Test App",
description="Test description"
)
assert app is not None
assert app.name == "Test App"
assert app.description == "Test description"
assert app.organization_id == org_id
assert app.created_by == user_id
assert app.is_active is True
assert plain_key == "wak_test_key_12345678901234567890123456789012345"
mock_db.add.assert_called_once()
mock_db.commit.assert_called_once()
mock_db.refresh.assert_called_once()
@patch('app.repositories.widget_app.generate_widget_api_key')
@patch('app.repositories.widget_app.hash_widget_api_key')
def test_create_app_without_description(
self, mock_hash, mock_generate, widget_app_repo, mock_db, org_id, user_id
):
"""Test creating app without optional description."""
mock_generate.return_value = "wak_test_key_12345678901234567890123456789012345"
mock_hash.return_value = "$2b$12$hashedkey"
def mock_refresh(app):
app.id = uuid4()
mock_db.refresh.side_effect = mock_refresh
app, plain_key = widget_app_repo.create_app(
organization_id=org_id,
created_by=user_id,
name="Simple App"
)
assert app is not None
assert app.name == "Simple App"
assert app.description is None
assert plain_key.startswith("wak_")
@patch('app.repositories.widget_app.generate_widget_api_key')
@patch('app.repositories.widget_app.hash_widget_api_key')
def test_create_multiple_apps_unique_keys(
self, mock_hash, mock_generate, widget_app_repo, mock_db, org_id, user_id
):
"""Test that multiple apps get unique API keys."""
keys = [
"wak_key1_12345678901234567890123456789012345",
"wak_key2_12345678901234567890123456789012345"
]
mock_generate.side_effect = keys
mock_hash.return_value = "$2b$12$hashedkey"
ids = [uuid4(), uuid4()]
call_count = [0]
def mock_refresh(app):
app.id = ids[call_count[0]]
call_count[0] += 1
mock_db.refresh.side_effect = mock_refresh
app1, key1 = widget_app_repo.create_app(
organization_id=org_id,
created_by=user_id,
name="App 1"
)
app2, key2 = widget_app_repo.create_app(
organization_id=org_id,
created_by=user_id,
name="App 2"
)
assert key1 != key2
assert app1.id != app2.id
class TestWidgetAppValidation:
"""A key is found by one indexed lookup; bcrypt only ever runs for keys
issued before the lookup column, once each."""
@pytest.fixture
def repo(self, db):
return WidgetAppRepository(db)
@pytest.fixture
def admin(self, db, test_organization):
user = User(id=uuid4(), email="apps@example.com", hashed_password="x", is_active=True,
organization_id=test_organization.id, full_name="Apps Admin")
db.add(user)
db.commit()
return user
@pytest.fixture
def new_app(self, repo, test_organization, admin):
return repo.create_app(organization_id=test_organization.id, created_by=admin.id, name="Site")
@pytest.fixture
def no_bcrypt(self):
with patch('app.repositories.widget_app.verify_widget_api_key', side_effect=AssertionError("bcrypt ran")) as verify:
yield verify
def test_a_new_key_is_one_lookup(self, repo, new_app, no_bcrypt):
app, key = new_app
assert repo.validate_api_key(key).id == app.id
def test_a_wrong_key_never_runs_bcrypt_once_every_app_is_indexed(self, repo, new_app, no_bcrypt):
assert repo.validate_api_key("wak_" + "x" * 43) is None
def test_a_key_issued_before_the_lookup_is_checked_once_then_indexed(self, db, repo, new_app):
app, key = new_app
app.api_key_lookup = None
db.commit()
assert repo.validate_api_key(key).id == app.id
assert app.api_key_lookup == widget_api_key_lookup(key)
with patch('app.repositories.widget_app.verify_widget_api_key', side_effect=AssertionError("bcrypt ran")):
assert repo.validate_api_key(key).id == app.id
def test_a_regenerated_key_replaces_the_old_one(self, repo, new_app, test_organization, no_bcrypt):
app, old_key = new_app
_, new_key = repo.regenerate_api_key(app.id, test_organization.id)
assert repo.validate_api_key(old_key) is None
assert repo.validate_api_key(new_key).id == app.id
def test_a_deactivated_apps_key_is_refused(self, repo, new_app, test_organization, no_bcrypt):
app, key = new_app
repo.deactivate_app(app.id, test_organization.id)
assert repo.validate_api_key(key) is None
class TestWidgetAppRetrieval:
"""Tests for retrieving widget apps."""
def test_get_app_by_id(self, widget_app_repo, mock_db, mock_widget_app, org_id):
"""Test getting app by ID."""
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_widget_app
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
retrieved = widget_app_repo.get_app_by_id(mock_widget_app.id, org_id)
assert retrieved is not None
assert retrieved.id == mock_widget_app.id
assert retrieved.name == mock_widget_app.name
def test_get_app_by_id_wrong_org(self, widget_app_repo, mock_db, org_id):
"""Test that apps are org-scoped."""
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = None
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
wrong_org = uuid4()
retrieved = widget_app_repo.get_app_by_id(uuid4(), wrong_org)
assert retrieved is None
def test_get_app_by_id_nonexistent(self, widget_app_repo, mock_db, org_id):
"""Test getting non-existent app returns None."""
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = None
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
fake_id = uuid4()
retrieved = widget_app_repo.get_app_by_id(fake_id, org_id)
assert retrieved is None
def test_get_apps_by_organization(self, widget_app_repo, mock_db, org_id, user_id):
"""Test listing all apps for an organization."""
apps = [
Mock(spec=WidgetApp, id=uuid4(), name=f"App {i}", organization_id=org_id, is_active=True)
for i in range(3)
]
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter2 = MagicMock()
mock_order = MagicMock()
mock_filter.filter.return_value = mock_filter2
mock_filter2.order_by.return_value = mock_order
mock_order.all.return_value = apps
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
result = widget_app_repo.get_apps_by_organization(org_id)
assert len(result) == 3
assert all(app.organization_id == org_id for app in result)
def test_get_apps_by_organization_exclude_inactive(self, widget_app_repo, mock_db, org_id):
"""Test that inactive apps are excluded by default."""
active_app = Mock(spec=WidgetApp, id=uuid4(), name="Active App", is_active=True)
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter2 = MagicMock()
mock_order = MagicMock()
mock_filter.filter.return_value = mock_filter2
mock_filter2.order_by.return_value = mock_order
mock_order.all.return_value = [active_app]
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
apps = widget_app_repo.get_apps_by_organization(org_id, include_inactive=False)
assert len(apps) == 1
assert apps[0].id == active_app.id
def test_get_apps_by_organization_include_inactive(self, widget_app_repo, mock_db, org_id):
"""Test getting all apps including inactive ones."""
apps = [
Mock(spec=WidgetApp, id=uuid4(), name="Active App", is_active=True),
Mock(spec=WidgetApp, id=uuid4(), name="Inactive App", is_active=False)
]
mock_query = MagicMock()
mock_filter = MagicMock()
mock_order = MagicMock()
mock_filter.order_by.return_value = mock_order
mock_order.all.return_value = apps
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
result = widget_app_repo.get_apps_by_organization(org_id, include_inactive=True)
assert len(result) == 2
def test_get_apps_by_organization_sorted(self, widget_app_repo, mock_db, org_id):
"""Test that apps are retrieved and have created_at timestamps."""
# Create mock apps with explicit attribute values
app1 = MagicMock()
app1.id = uuid4()
app1.name = "First App"
app1.created_at = datetime.utcnow()
app2 = MagicMock()
app2.id = uuid4()
app2.name = "Second App"
app2.created_at = datetime.utcnow()
app3 = MagicMock()
app3.id = uuid4()
app3.name = "Third App"
app3.created_at = datetime.utcnow()
apps = [app1, app2, app3]
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter2 = MagicMock()
mock_order = MagicMock()
mock_filter.filter.return_value = mock_filter2
mock_filter2.order_by.return_value = mock_order
mock_order.all.return_value = apps
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
result = widget_app_repo.get_apps_by_organization(org_id)
assert len(result) == 3
assert all(app.created_at is not None for app in result)
app_names = {app.name for app in result}
assert app_names == {"First App", "Second App", "Third App"}
class TestWidgetAppUpdate:
"""Tests for updating widget apps."""
def test_update_app_name(self, widget_app_repo, mock_db, mock_widget_app, org_id):
"""Test updating app name."""
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_widget_app
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
updated = widget_app_repo.update_app(
mock_widget_app.id,
org_id,
name="New Name"
)
assert updated is not None
assert updated.name == "New Name"
mock_db.commit.assert_called_once()
def test_update_app_description(self, widget_app_repo, mock_db, mock_widget_app, org_id):
"""Test updating app description."""
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_widget_app
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
updated = widget_app_repo.update_app(
mock_widget_app.id,
org_id,
description="New description"
)
assert updated is not None
assert updated.description == "New description"
def test_update_app_is_active(self, widget_app_repo, mock_db, mock_widget_app, org_id):
"""Test updating app active status."""
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_widget_app
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
updated = widget_app_repo.update_app(
mock_widget_app.id,
org_id,
is_active=False
)
assert updated is not None
assert updated.is_active is False
def test_update_app_multiple_fields(self, widget_app_repo, mock_db, mock_widget_app, org_id):
"""Test updating multiple fields at once."""
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_widget_app
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
updated = widget_app_repo.update_app(
mock_widget_app.id,
org_id,
name="New Name",
description="New Description",
is_active=False
)
assert updated is not None
assert updated.name == "New Name"
assert updated.description == "New Description"
assert updated.is_active is False
def test_update_app_nonexistent(self, widget_app_repo, mock_db, org_id):
"""Test updating non-existent app returns None."""
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = None
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
fake_id = uuid4()
updated = widget_app_repo.update_app(fake_id, org_id, name="New Name")
assert updated is None
def test_update_app_wrong_org(self, widget_app_repo, mock_db, mock_widget_app):
"""Test updating app from wrong org returns None."""
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = None
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
wrong_org = uuid4()
updated = widget_app_repo.update_app(mock_widget_app.id, wrong_org, name="New Name")
assert updated is None
class TestWidgetAppDeletion:
"""Tests for deleting/deactivating widget apps."""
def test_deactivate_app(self, widget_app_repo, mock_db, mock_widget_app, org_id):
"""Test soft delete (deactivation)."""
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_widget_app
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
success = widget_app_repo.deactivate_app(mock_widget_app.id, org_id)
assert success is True
assert mock_widget_app.is_active is False
mock_db.commit.assert_called_once()
def test_deactivate_app_nonexistent(self, widget_app_repo, mock_db, org_id):
"""Test deactivating non-existent app returns False."""
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = None
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
fake_id = uuid4()
success = widget_app_repo.deactivate_app(fake_id, org_id)
assert success is False
def test_delete_app_hard(self, widget_app_repo, mock_db, mock_widget_app, org_id):
"""Test hard delete (permanent removal)."""
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_widget_app
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
success = widget_app_repo.delete_app(mock_widget_app.id, org_id)
assert success is True
mock_db.delete.assert_called_once_with(mock_widget_app)
mock_db.commit.assert_called_once()
def test_delete_app_nonexistent(self, widget_app_repo, mock_db, org_id):
"""Test hard deleting non-existent app returns False."""
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = None
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
fake_id = uuid4()
success = widget_app_repo.delete_app(fake_id, org_id)
assert success is False
class TestAPIKeyRegeneration:
"""Tests for regenerating API keys."""
@patch('app.repositories.widget_app.generate_widget_api_key')
@patch('app.repositories.widget_app.hash_widget_api_key')
def test_regenerate_api_key(
self, mock_hash, mock_generate, widget_app_repo, mock_db, mock_widget_app, org_id
):
"""Test API key regeneration."""
old_hash = mock_widget_app.api_key_hash
mock_generate.return_value = "wak_new_key_12345678901234567890123456789012345"
mock_hash.return_value = "$2b$12$newhash"
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = mock_widget_app
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
result = widget_app_repo.regenerate_api_key(mock_widget_app.id, org_id)
assert result is not None
updated_app, new_key = result
assert new_key == "wak_new_key_12345678901234567890123456789012345"
assert new_key.startswith("wak_")
mock_db.commit.assert_called_once()
def test_regenerate_api_key_nonexistent(self, widget_app_repo, mock_db, org_id):
"""Test regenerating key for non-existent app returns None."""
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = None
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
fake_id = uuid4()
result = widget_app_repo.regenerate_api_key(fake_id, org_id)
assert result is None
def test_regenerate_api_key_wrong_org(self, widget_app_repo, mock_db, mock_widget_app):
"""Test regenerating key from wrong org returns None."""
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = None
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
wrong_org = uuid4()
result = widget_app_repo.regenerate_api_key(mock_widget_app.id, wrong_org)
assert result is None
class TestOrganizationIsolation:
"""Tests for ensuring organization-level isolation."""
def test_apps_isolated_by_organization(self, widget_app_repo, mock_db, user_id):
"""Test that apps from different orgs are isolated."""
org1 = uuid4()
org2 = uuid4()
app1 = Mock(spec=WidgetApp, id=uuid4(), name="Org1 App", organization_id=org1)
app2 = Mock(spec=WidgetApp, id=uuid4(), name="Org2 App", organization_id=org2)
call_count = [0]
def get_apps_for_org(org_id):
if org_id == org1:
return [app1]
elif org_id == org2:
return [app2]
return []
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter2 = MagicMock()
mock_order = MagicMock()
def setup_filter(*args):
mock_filter.filter.return_value = mock_filter2
mock_filter2.order_by.return_value = mock_order
# Return different results based on call
if call_count[0] == 0:
mock_order.all.return_value = [app1]
else:
mock_order.all.return_value = [app2]
call_count[0] += 1
return mock_filter
mock_query.filter.side_effect = setup_filter
mock_db.query.return_value = mock_query
org1_apps = widget_app_repo.get_apps_by_organization(org1)
org2_apps = widget_app_repo.get_apps_by_organization(org2)
assert len(org1_apps) == 1
assert len(org2_apps) == 1
assert org1_apps[0].id == app1.id
assert org2_apps[0].id == app2.id
def test_cannot_access_other_org_app(self, widget_app_repo, mock_db):
"""Test that one org cannot access another org's app."""
org1 = uuid4()
org2 = uuid4()
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.first.return_value = None # App not found for wrong org
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
app1_id = uuid4()
retrieved = widget_app_repo.get_app_by_id(app1_id, org2)
assert retrieved is None
class TestAPIKeyFormat:
"""Tests for API key format and security."""
@patch('app.repositories.widget_app.generate_widget_api_key')
@patch('app.repositories.widget_app.hash_widget_api_key')
def test_api_key_format(
self, mock_hash, mock_generate, widget_app_repo, mock_db, org_id, user_id
):
"""Test that generated API keys follow the correct format."""
# Test key with proper format
test_key = "wak_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmno"
mock_generate.return_value = test_key
mock_hash.return_value = "$2b$12$hashedkey"
def mock_refresh(app):
app.id = uuid4()
mock_db.refresh.side_effect = mock_refresh
_, plain_key = widget_app_repo.create_app(org_id, user_id, "Test App")
# Should start with wak_
assert plain_key.startswith("wak_")
# Should be URL-safe base64 (only alphanumeric, -, _)
key_suffix = plain_key[4:] # Remove wak_ prefix
assert all(c.isalnum() or c in ['-', '_'] for c in key_suffix)
# Should have sufficient length
assert len(key_suffix) >= 40
@patch('app.repositories.widget_app.generate_widget_api_key')
@patch('app.repositories.widget_app.hash_widget_api_key')
def test_api_key_hash_storage(
self, mock_hash, mock_generate, widget_app_repo, mock_db, org_id, user_id
):
"""Test that API keys are stored as hashes, not plain text."""
plain_key = "wak_plaintext_key_12345678901234567890"
hashed_key = "$2b$12$hashedvalue123456789012345678901234"
mock_generate.return_value = plain_key
mock_hash.return_value = hashed_key
def mock_refresh(app):
app.id = uuid4()
mock_db.refresh.side_effect = mock_refresh
app, returned_key = widget_app_repo.create_app(org_id, user_id, "Test App")
# The stored hash should not equal the plain key
assert app.api_key_hash != returned_key
# The hash should look like a bcrypt hash
assert app.api_key_hash.startswith("$2b$")
@patch('app.repositories.widget_app.generate_widget_api_key')
@patch('app.repositories.widget_app.hash_widget_api_key')
def test_api_key_uniqueness(
self, mock_hash, mock_generate, widget_app_repo, mock_db, org_id, user_id
):
"""Test that each generated API key is unique."""
keys = [f"wak_unique_key_{i}_{'x' * 30}" for i in range(10)]
mock_generate.side_effect = keys
mock_hash.return_value = "$2b$12$hashedkey"
ids = [uuid4() for _ in range(10)]
call_count = [0]
def mock_refresh(app):
app.id = ids[call_count[0]]
call_count[0] += 1
mock_db.refresh.side_effect = mock_refresh
generated_keys = set()
for i in range(10):
_, plain_key = widget_app_repo.create_app(
org_id, user_id, f"App {i}"
)
generated_keys.add(plain_key)
# All keys should be unique
assert len(generated_keys) == 10
class TestErrorHandling:
"""Tests for error handling."""
@patch('app.repositories.widget_app.generate_widget_api_key')
@patch('app.repositories.widget_app.hash_widget_api_key')
def test_create_app_rollback_on_error(
self, mock_hash, mock_generate, widget_app_repo, mock_db, org_id, user_id
):
"""Test that database rollback occurs on error during creation."""
mock_generate.return_value = "wak_test_key"
mock_hash.return_value = "$2b$12$hash"
mock_db.commit.side_effect = Exception("Database error")
with pytest.raises(Exception, match="Database error"):
widget_app_repo.create_app(org_id, user_id, "Test App")
mock_db.rollback.assert_called_once()