test: 补充知识处理、检索与 Agent 回归用例
覆盖切块、向量检索、工具调用和历史预算;模型相关用例使用桩。
This commit is contained in:
1 parent
2dd062787d
commit
d9fe727c3f
123 files changed
+29005
No files matched your search
@@ -0,0 +1,487 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import uuid
|
||||
from unittest.mock import MagicMock, patch
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.repositories.agent_shopify_config_repository import AgentShopifyConfigRepository
|
||||
from app.models.shopify.agent_shopify_config import AgentShopifyConfig
|
||||
from app.models.schemas.shopify.agent_shopify_config import (
|
||||
AgentShopifyConfigCreate,
|
||||
AgentShopifyConfigUpdate
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
"""Create a mock database session"""
|
||||
db = MagicMock(spec=Session)
|
||||
return db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_config_data():
|
||||
"""Sample agent shopify config data for testing"""
|
||||
return {
|
||||
"id": str(uuid.uuid4()),
|
||||
"agent_id": str(uuid.uuid4()),
|
||||
"shop_id": str(uuid.uuid4()),
|
||||
"enabled": True,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"updated_at": datetime.now(timezone.utc)
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(sample_config_data):
|
||||
"""Create a mock AgentShopifyConfig instance"""
|
||||
config = MagicMock(spec=AgentShopifyConfig)
|
||||
for key, value in sample_config_data.items():
|
||||
setattr(config, key, value)
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repository(mock_db):
|
||||
"""Create an AgentShopifyConfigRepository instance with mocked dependencies"""
|
||||
return AgentShopifyConfigRepository(mock_db)
|
||||
|
||||
|
||||
class TestAgentShopifyConfigRepository:
|
||||
"""Test cases for AgentShopifyConfigRepository"""
|
||||
|
||||
def test_get_agent_shopify_config_success(self, repository, mock_db, mock_config, sample_config_data):
|
||||
"""Test getting an agent shopify config by agent_id"""
|
||||
# Arrange
|
||||
agent_id = sample_config_data["agent_id"]
|
||||
query_mock = MagicMock()
|
||||
filter_mock = MagicMock()
|
||||
|
||||
mock_db.query.return_value = query_mock
|
||||
query_mock.filter.return_value = filter_mock
|
||||
filter_mock.first.return_value = mock_config
|
||||
|
||||
# Act
|
||||
result = repository.get_agent_shopify_config(agent_id)
|
||||
|
||||
# Assert
|
||||
assert result == mock_config
|
||||
mock_db.query.assert_called_once_with(AgentShopifyConfig)
|
||||
query_mock.filter.assert_called_once()
|
||||
filter_mock.first.assert_called_once()
|
||||
assert result.agent_id == sample_config_data["agent_id"]
|
||||
|
||||
def test_get_agent_shopify_config_not_found(self, repository, mock_db):
|
||||
"""Test getting an agent shopify config that doesn't exist"""
|
||||
# Arrange
|
||||
agent_id = str(uuid.uuid4())
|
||||
query_mock = MagicMock()
|
||||
filter_mock = MagicMock()
|
||||
|
||||
mock_db.query.return_value = query_mock
|
||||
query_mock.filter.return_value = filter_mock
|
||||
filter_mock.first.return_value = None
|
||||
|
||||
# Act
|
||||
result = repository.get_agent_shopify_config(agent_id)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
mock_db.query.assert_called_once_with(AgentShopifyConfig)
|
||||
query_mock.filter.assert_called_once()
|
||||
filter_mock.first.assert_called_once()
|
||||
|
||||
def test_get_config_by_agent_and_shop_success(self, repository, mock_db, mock_config, sample_config_data):
|
||||
"""Test getting config by agent and shop combination"""
|
||||
# Arrange
|
||||
agent_id = sample_config_data["agent_id"]
|
||||
shop_id = sample_config_data["shop_id"]
|
||||
query_mock = MagicMock()
|
||||
filter_mock = MagicMock()
|
||||
|
||||
mock_db.query.return_value = query_mock
|
||||
query_mock.filter.return_value = filter_mock
|
||||
filter_mock.first.return_value = mock_config
|
||||
|
||||
# Act
|
||||
result = repository.get_config_by_agent_and_shop(agent_id, shop_id)
|
||||
|
||||
# Assert
|
||||
assert result == mock_config
|
||||
mock_db.query.assert_called_once_with(AgentShopifyConfig)
|
||||
query_mock.filter.assert_called_once()
|
||||
filter_mock.first.assert_called_once()
|
||||
|
||||
def test_get_config_by_agent_and_shop_not_found(self, repository, mock_db):
|
||||
"""Test getting config by agent and shop when it doesn't exist"""
|
||||
# Arrange
|
||||
agent_id = str(uuid.uuid4())
|
||||
shop_id = str(uuid.uuid4())
|
||||
query_mock = MagicMock()
|
||||
filter_mock = MagicMock()
|
||||
|
||||
mock_db.query.return_value = query_mock
|
||||
query_mock.filter.return_value = filter_mock
|
||||
filter_mock.first.return_value = None
|
||||
|
||||
# Act
|
||||
result = repository.get_config_by_agent_and_shop(agent_id, shop_id)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
mock_db.query.assert_called_once_with(AgentShopifyConfig)
|
||||
query_mock.filter.assert_called_once()
|
||||
filter_mock.first.assert_called_once()
|
||||
|
||||
def test_get_configs_by_shop_all(self, repository, mock_db):
|
||||
"""Test getting all configs for a shop"""
|
||||
# Arrange
|
||||
shop_id = str(uuid.uuid4())
|
||||
configs = [MagicMock(spec=AgentShopifyConfig) for _ in range(3)]
|
||||
for i, config in enumerate(configs):
|
||||
config.id = str(uuid.uuid4())
|
||||
config.agent_id = str(uuid.uuid4())
|
||||
config.shop_id = shop_id
|
||||
config.enabled = i % 2 == 0 # Mix of enabled/disabled
|
||||
|
||||
query_mock = MagicMock()
|
||||
filter_mock = MagicMock()
|
||||
|
||||
mock_db.query.return_value = query_mock
|
||||
query_mock.filter.return_value = filter_mock
|
||||
filter_mock.all.return_value = configs
|
||||
|
||||
# Act
|
||||
result = repository.get_configs_by_shop(shop_id)
|
||||
|
||||
# Assert
|
||||
assert result == configs
|
||||
assert len(result) == 3
|
||||
mock_db.query.assert_called_once_with(AgentShopifyConfig)
|
||||
query_mock.filter.assert_called_once()
|
||||
filter_mock.all.assert_called_once()
|
||||
|
||||
def test_get_configs_by_shop_enabled_only(self, repository, mock_db):
|
||||
"""Test getting only enabled configs for a shop"""
|
||||
# Arrange
|
||||
shop_id = str(uuid.uuid4())
|
||||
enabled_configs = [MagicMock(spec=AgentShopifyConfig) for _ in range(2)]
|
||||
for config in enabled_configs:
|
||||
config.id = str(uuid.uuid4())
|
||||
config.agent_id = str(uuid.uuid4())
|
||||
config.shop_id = shop_id
|
||||
config.enabled = True
|
||||
|
||||
query_mock = MagicMock()
|
||||
filter_mock1 = MagicMock()
|
||||
filter_mock2 = MagicMock()
|
||||
|
||||
mock_db.query.return_value = query_mock
|
||||
query_mock.filter.return_value = filter_mock1
|
||||
filter_mock1.filter.return_value = filter_mock2
|
||||
filter_mock2.all.return_value = enabled_configs
|
||||
|
||||
# Act
|
||||
result = repository.get_configs_by_shop(shop_id, enabled_only=True)
|
||||
|
||||
# Assert
|
||||
assert result == enabled_configs
|
||||
assert len(result) == 2
|
||||
mock_db.query.assert_called_once_with(AgentShopifyConfig)
|
||||
# Should be called twice - once for shop_id, once for enabled filter
|
||||
assert query_mock.filter.call_count == 1
|
||||
assert filter_mock1.filter.call_count == 1
|
||||
filter_mock2.all.assert_called_once()
|
||||
|
||||
def test_get_enabled_configs_for_org(self, repository, mock_db):
|
||||
"""Test getting enabled configs for an organization"""
|
||||
# Arrange
|
||||
organization_id = str(uuid.uuid4())
|
||||
configs = [MagicMock(spec=AgentShopifyConfig) for _ in range(2)]
|
||||
for config in configs:
|
||||
config.id = str(uuid.uuid4())
|
||||
config.agent_id = str(uuid.uuid4())
|
||||
config.enabled = True
|
||||
|
||||
query_mock = MagicMock()
|
||||
join_mock = MagicMock()
|
||||
filter_mock = MagicMock()
|
||||
|
||||
mock_db.query.return_value = query_mock
|
||||
query_mock.join.return_value = join_mock
|
||||
join_mock.filter.return_value = filter_mock
|
||||
filter_mock.all.return_value = configs
|
||||
|
||||
# Act
|
||||
result = repository.get_enabled_configs_for_org(organization_id)
|
||||
|
||||
# Assert
|
||||
assert result == configs
|
||||
assert len(result) == 2
|
||||
mock_db.query.assert_called_once_with(AgentShopifyConfig)
|
||||
query_mock.join.assert_called_once()
|
||||
join_mock.filter.assert_called_once()
|
||||
filter_mock.all.assert_called_once()
|
||||
|
||||
def test_create_agent_shopify_config(self, repository, mock_db, sample_config_data):
|
||||
"""Test creating a new agent shopify config"""
|
||||
# Arrange
|
||||
config_create_data = {
|
||||
"agent_id": sample_config_data["agent_id"],
|
||||
"shop_id": sample_config_data["shop_id"],
|
||||
"enabled": sample_config_data["enabled"]
|
||||
}
|
||||
config_create = AgentShopifyConfigCreate(**config_create_data)
|
||||
|
||||
# Mock the database operations
|
||||
mock_db.add = MagicMock()
|
||||
mock_db.commit = MagicMock()
|
||||
mock_db.refresh = MagicMock()
|
||||
|
||||
# Act
|
||||
with patch('uuid.uuid4', return_value=uuid.UUID(sample_config_data["id"])):
|
||||
result = repository.create_agent_shopify_config(config_create)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, AgentShopifyConfig)
|
||||
assert result.agent_id == config_create.agent_id
|
||||
assert result.shop_id == config_create.shop_id
|
||||
assert result.enabled == config_create.enabled
|
||||
assert result.id == sample_config_data["id"]
|
||||
|
||||
mock_db.add.assert_called_once()
|
||||
mock_db.commit.assert_called_once()
|
||||
mock_db.refresh.assert_called_once()
|
||||
|
||||
def test_create_or_update_agent_shopify_config_create_new(self, repository, mock_db, sample_config_data):
|
||||
"""Test create_or_update when no existing config exists (creates new)"""
|
||||
# Arrange
|
||||
config_create_data = {
|
||||
"agent_id": sample_config_data["agent_id"],
|
||||
"shop_id": sample_config_data["shop_id"],
|
||||
"enabled": sample_config_data["enabled"]
|
||||
}
|
||||
config_create = AgentShopifyConfigCreate(**config_create_data)
|
||||
|
||||
# Mock get_agent_shopify_config to return None (no existing config)
|
||||
repository.get_agent_shopify_config = MagicMock(return_value=None)
|
||||
|
||||
# Mock the database operations
|
||||
mock_db.add = MagicMock()
|
||||
mock_db.commit = MagicMock()
|
||||
mock_db.refresh = MagicMock()
|
||||
|
||||
# Act
|
||||
with patch('uuid.uuid4', return_value=uuid.UUID(sample_config_data["id"])):
|
||||
result = repository.create_or_update_agent_shopify_config(config_create)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, AgentShopifyConfig)
|
||||
assert result.agent_id == config_create.agent_id
|
||||
assert result.shop_id == config_create.shop_id
|
||||
assert result.enabled == config_create.enabled
|
||||
|
||||
repository.get_agent_shopify_config.assert_called_once_with(config_create.agent_id)
|
||||
mock_db.add.assert_called_once()
|
||||
mock_db.commit.assert_called_once()
|
||||
mock_db.refresh.assert_called_once()
|
||||
|
||||
def test_create_or_update_agent_shopify_config_update_existing(self, repository, mock_db, mock_config, sample_config_data):
|
||||
"""Test create_or_update when existing config exists (updates existing)"""
|
||||
# Arrange
|
||||
new_shop_id = str(uuid.uuid4())
|
||||
config_create_data = {
|
||||
"agent_id": sample_config_data["agent_id"],
|
||||
"shop_id": new_shop_id,
|
||||
"enabled": False # Different from original
|
||||
}
|
||||
config_create = AgentShopifyConfigCreate(**config_create_data)
|
||||
|
||||
# Mock get_agent_shopify_config to return existing config
|
||||
repository.get_agent_shopify_config = MagicMock(return_value=mock_config)
|
||||
|
||||
# Mock the database operations
|
||||
mock_db.commit = MagicMock()
|
||||
mock_db.refresh = MagicMock()
|
||||
|
||||
# Act
|
||||
result = repository.create_or_update_agent_shopify_config(config_create)
|
||||
|
||||
# Assert
|
||||
assert result == mock_config
|
||||
assert mock_config.shop_id == new_shop_id
|
||||
assert mock_config.enabled == False
|
||||
|
||||
repository.get_agent_shopify_config.assert_called_once_with(config_create.agent_id)
|
||||
mock_db.commit.assert_called_once()
|
||||
mock_db.refresh.assert_called_once_with(mock_config)
|
||||
|
||||
def test_update_agent_shopify_config_success(self, repository, mock_db, mock_config, sample_config_data):
|
||||
"""Test updating an existing agent shopify config"""
|
||||
# Arrange
|
||||
agent_id = sample_config_data["agent_id"]
|
||||
update_data = {
|
||||
"enabled": False,
|
||||
"shop_id": str(uuid.uuid4())
|
||||
}
|
||||
|
||||
# Create a mock update object instead of real Pydantic model
|
||||
mock_config_update = MagicMock()
|
||||
mock_config_update.dict.return_value = update_data
|
||||
|
||||
# Mock get_agent_shopify_config to return the mock_config
|
||||
repository.get_agent_shopify_config = MagicMock(return_value=mock_config)
|
||||
|
||||
# Mock the database operations
|
||||
mock_db.commit = MagicMock()
|
||||
mock_db.refresh = MagicMock()
|
||||
|
||||
# Act
|
||||
result = repository.update_agent_shopify_config(agent_id, mock_config_update)
|
||||
|
||||
# Assert
|
||||
assert result == mock_config
|
||||
repository.get_agent_shopify_config.assert_called_once_with(agent_id)
|
||||
|
||||
# Verify the dict method was called with exclude_unset=True
|
||||
mock_config_update.dict.assert_called_once_with(exclude_unset=True)
|
||||
|
||||
# Verify the fields were updated
|
||||
assert mock_config.enabled == update_data["enabled"]
|
||||
assert mock_config.shop_id == update_data["shop_id"]
|
||||
|
||||
mock_db.commit.assert_called_once()
|
||||
mock_db.refresh.assert_called_once_with(mock_config)
|
||||
|
||||
def test_update_agent_shopify_config_not_found(self, repository, mock_db):
|
||||
"""Test updating an agent shopify config that doesn't exist"""
|
||||
# Arrange
|
||||
agent_id = str(uuid.uuid4())
|
||||
update_data = {
|
||||
"enabled": False,
|
||||
"shop_id": str(uuid.uuid4())
|
||||
}
|
||||
config_update = AgentShopifyConfigUpdate(**update_data)
|
||||
|
||||
# Mock get_agent_shopify_config to return None (config not found)
|
||||
repository.get_agent_shopify_config = MagicMock(return_value=None)
|
||||
|
||||
# Act
|
||||
result = repository.update_agent_shopify_config(agent_id, config_update)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
repository.get_agent_shopify_config.assert_called_once_with(agent_id)
|
||||
mock_db.commit.assert_not_called()
|
||||
mock_db.refresh.assert_not_called()
|
||||
|
||||
def test_delete_agent_shopify_config_success(self, repository, mock_db, mock_config):
|
||||
"""Test deleting an agent shopify config"""
|
||||
# Arrange
|
||||
agent_id = mock_config.agent_id
|
||||
|
||||
# Mock get_agent_shopify_config to return the mock_config
|
||||
repository.get_agent_shopify_config = MagicMock(return_value=mock_config)
|
||||
|
||||
# Mock the database operations
|
||||
mock_db.delete = MagicMock()
|
||||
mock_db.commit = MagicMock()
|
||||
|
||||
# Act
|
||||
result = repository.delete_agent_shopify_config(agent_id)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
repository.get_agent_shopify_config.assert_called_once_with(agent_id)
|
||||
mock_db.delete.assert_called_once_with(mock_config)
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_delete_agent_shopify_config_not_found(self, repository, mock_db):
|
||||
"""Test deleting an agent shopify config that doesn't exist"""
|
||||
# Arrange
|
||||
agent_id = str(uuid.uuid4())
|
||||
|
||||
# Mock get_agent_shopify_config to return None (config not found)
|
||||
repository.get_agent_shopify_config = MagicMock(return_value=None)
|
||||
|
||||
# Act
|
||||
result = repository.delete_agent_shopify_config(agent_id)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
repository.get_agent_shopify_config.assert_called_once_with(agent_id)
|
||||
mock_db.delete.assert_not_called()
|
||||
mock_db.commit.assert_not_called()
|
||||
|
||||
def test_update_config_object(self, repository, mock_db, mock_config):
|
||||
"""Test updating an existing config object directly"""
|
||||
# Arrange
|
||||
mock_db.commit = MagicMock()
|
||||
mock_db.refresh = MagicMock()
|
||||
|
||||
# Act
|
||||
result = repository.update(mock_config)
|
||||
|
||||
# Assert
|
||||
assert result == mock_config
|
||||
mock_db.commit.assert_called_once()
|
||||
mock_db.refresh.assert_called_once_with(mock_config)
|
||||
|
||||
def test_get_configs_by_shop_empty_result(self, repository, mock_db):
|
||||
"""Test getting configs for a shop with no results"""
|
||||
# Arrange
|
||||
shop_id = str(uuid.uuid4())
|
||||
query_mock = MagicMock()
|
||||
filter_mock = MagicMock()
|
||||
|
||||
mock_db.query.return_value = query_mock
|
||||
query_mock.filter.return_value = filter_mock
|
||||
filter_mock.all.return_value = []
|
||||
|
||||
# Act
|
||||
result = repository.get_configs_by_shop(shop_id)
|
||||
|
||||
# Assert
|
||||
assert result == []
|
||||
mock_db.query.assert_called_once_with(AgentShopifyConfig)
|
||||
query_mock.filter.assert_called_once()
|
||||
filter_mock.all.assert_called_once()
|
||||
|
||||
def test_get_enabled_configs_for_org_empty_result(self, repository, mock_db):
|
||||
"""Test getting enabled configs for an organization with no results"""
|
||||
# Arrange
|
||||
organization_id = str(uuid.uuid4())
|
||||
query_mock = MagicMock()
|
||||
join_mock = MagicMock()
|
||||
filter_mock = MagicMock()
|
||||
|
||||
mock_db.query.return_value = query_mock
|
||||
query_mock.join.return_value = join_mock
|
||||
join_mock.filter.return_value = filter_mock
|
||||
filter_mock.all.return_value = []
|
||||
|
||||
# Act
|
||||
result = repository.get_enabled_configs_for_org(organization_id)
|
||||
|
||||
# Assert
|
||||
assert result == []
|
||||
mock_db.query.assert_called_once_with(AgentShopifyConfig)
|
||||
query_mock.join.assert_called_once()
|
||||
join_mock.filter.assert_called_once()
|
||||
filter_mock.all.assert_called_once()
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.channels.accounts import to_account_out
|
||||
from app.channels.base import SendResult
|
||||
from app.repositories.channels import ChannelAccountRepository
|
||||
from app.repositories.channels.accounts import AUTH_FAILURE_SETTING, needs_reconnect
|
||||
|
||||
AUTH_FAILED = SendResult(ok=False, error="Authentication Error", auth_failed=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(db):
|
||||
return ChannelAccountRepository(db)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def account(repo, test_organization):
|
||||
return repo.create_account(
|
||||
organization_id=test_organization.id,
|
||||
channel_type="whatsapp",
|
||||
external_account_id="PN-auth",
|
||||
credentials={"access_token": "old"},
|
||||
display_name="Test Number",
|
||||
settings={"meta_app_id": "123"},
|
||||
)
|
||||
|
||||
|
||||
def _reloaded(db, repo, account):
|
||||
db.expire_all()
|
||||
return repo.get_by_id(account.id)
|
||||
|
||||
|
||||
def test_a_new_account_does_not_need_reconnecting(account):
|
||||
assert needs_reconnect(account) is False
|
||||
|
||||
|
||||
def test_an_auth_failure_marks_the_account_and_keeps_its_settings(db, repo, account):
|
||||
repo.track_send_result(account, AUTH_FAILED)
|
||||
|
||||
stored = _reloaded(db, repo, account)
|
||||
assert needs_reconnect(stored) is True
|
||||
assert stored.settings[AUTH_FAILURE_SETTING]["error"] == "Authentication Error"
|
||||
assert stored.settings["meta_app_id"] == "123"
|
||||
|
||||
|
||||
def test_repeated_failures_keep_the_first_failure_time(db, repo, account):
|
||||
repo.track_send_result(account, AUTH_FAILED)
|
||||
first = _reloaded(db, repo, account).settings[AUTH_FAILURE_SETTING]["at"]
|
||||
|
||||
repo.track_send_result(account, SendResult(ok=False, error="later", auth_failed=True))
|
||||
|
||||
stored = _reloaded(db, repo, account)
|
||||
assert stored.settings[AUTH_FAILURE_SETTING] == {"error": "Authentication Error", "at": first}
|
||||
|
||||
|
||||
def test_a_failure_that_is_not_about_credentials_changes_nothing(db, repo, account):
|
||||
repo.track_send_result(account, SendResult(ok=False, error="Re-engagement message"))
|
||||
assert needs_reconnect(_reloaded(db, repo, account)) is False
|
||||
|
||||
|
||||
def test_a_successful_send_clears_the_flag(db, repo, account):
|
||||
repo.track_send_result(account, AUTH_FAILED)
|
||||
repo.track_send_result(account, SendResult(ok=True, external_message_id="wamid.1"))
|
||||
|
||||
stored = _reloaded(db, repo, account)
|
||||
assert needs_reconnect(stored) is False
|
||||
assert stored.settings == {"meta_app_id": "123"}
|
||||
|
||||
|
||||
def test_a_failed_send_after_recovery_does_not_clear_or_set_it(db, repo, account):
|
||||
"""Only a send that worked proves the credentials; a plain failure says
|
||||
nothing either way."""
|
||||
repo.track_send_result(account, AUTH_FAILED)
|
||||
repo.track_send_result(account, SendResult(ok=False, error="Re-engagement message"))
|
||||
assert needs_reconnect(_reloaded(db, repo, account)) is True
|
||||
|
||||
|
||||
def test_reconnecting_with_new_credentials_clears_the_flag(db, repo, account):
|
||||
repo.track_send_result(account, AUTH_FAILED)
|
||||
|
||||
repo.update_credentials(account, {"access_token": "new"})
|
||||
|
||||
stored = _reloaded(db, repo, account)
|
||||
assert needs_reconnect(stored) is False
|
||||
assert repo.get_credentials(stored) == {"access_token": "new"}
|
||||
assert stored.settings == {"meta_app_id": "123"}
|
||||
|
||||
|
||||
def test_the_account_api_reports_it(db, repo, account):
|
||||
assert to_account_out(db, account).needs_reconnect is False
|
||||
|
||||
repo.track_send_result(account, AUTH_FAILED)
|
||||
|
||||
assert to_account_out(db, _reloaded(db, repo, account)).needs_reconnect is True
|
||||
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from uuid import uuid4
|
||||
from unittest.mock import patch
|
||||
from app.repositories.chat import ChatRepository
|
||||
from app.models.chat_history import ChatHistory
|
||||
from app.models.session_to_agent import SessionToAgent, SessionStatus
|
||||
|
||||
|
||||
class TestChatSentimentIntegration:
|
||||
"""Tests for sentiment analysis integration in ChatRepository."""
|
||||
|
||||
@pytest.fixture
|
||||
def chat_repo(self, db):
|
||||
return ChatRepository(db)
|
||||
|
||||
@pytest.fixture
|
||||
def test_session(self, db, test_organization, test_agent, test_customer):
|
||||
session_id = uuid4()
|
||||
session = SessionToAgent(
|
||||
session_id=session_id,
|
||||
agent_id=test_agent.id,
|
||||
customer_id=test_customer.id,
|
||||
organization_id=test_organization.id,
|
||||
status=SessionStatus.OPEN
|
||||
)
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
return session
|
||||
|
||||
def test_create_user_message_adds_sentiment(self, chat_repo, test_organization, test_agent, test_customer, test_session):
|
||||
"""User messages should have sentiment analysis applied."""
|
||||
message = chat_repo.create_message({
|
||||
"organization_id": test_organization.id,
|
||||
"agent_id": test_agent.id,
|
||||
"customer_id": test_customer.id,
|
||||
"session_id": test_session.session_id,
|
||||
"message": "This is great, thank you so much!",
|
||||
"message_type": "user"
|
||||
})
|
||||
assert message.sentiment_label is not None
|
||||
assert message.sentiment_label in ("positive", "neutral", "negative")
|
||||
assert message.sentiment_score is not None
|
||||
|
||||
def test_create_bot_message_no_sentiment(self, chat_repo, test_organization, test_agent, test_customer, test_session):
|
||||
"""Bot messages should NOT have sentiment analysis."""
|
||||
message = chat_repo.create_message({
|
||||
"organization_id": test_organization.id,
|
||||
"agent_id": test_agent.id,
|
||||
"customer_id": test_customer.id,
|
||||
"session_id": test_session.session_id,
|
||||
"message": "How can I help you today?",
|
||||
"message_type": "bot"
|
||||
})
|
||||
assert message.sentiment_label is None
|
||||
assert message.sentiment_score is None
|
||||
|
||||
def test_session_sentiment_updated_after_user_message(self, chat_repo, test_organization, test_agent, test_customer, test_session, db):
|
||||
"""Session sentiment should be updated after a user message."""
|
||||
chat_repo.create_message({
|
||||
"organization_id": test_organization.id,
|
||||
"agent_id": test_agent.id,
|
||||
"customer_id": test_customer.id,
|
||||
"session_id": test_session.session_id,
|
||||
"message": "This is terrible and broken, I hate it",
|
||||
"message_type": "user"
|
||||
})
|
||||
db.refresh(test_session)
|
||||
assert test_session.sentiment_label is not None
|
||||
assert test_session.sentiment_score is not None
|
||||
|
||||
def test_session_sentiment_averages_multiple_messages(self, chat_repo, test_organization, test_agent, test_customer, test_session, db):
|
||||
"""Session sentiment should average across multiple user messages."""
|
||||
# Send a positive message
|
||||
chat_repo.create_message({
|
||||
"organization_id": test_organization.id,
|
||||
"agent_id": test_agent.id,
|
||||
"customer_id": test_customer.id,
|
||||
"session_id": test_session.session_id,
|
||||
"message": "thanks great awesome wonderful",
|
||||
"message_type": "user"
|
||||
})
|
||||
# Send a negative message
|
||||
chat_repo.create_message({
|
||||
"organization_id": test_organization.id,
|
||||
"agent_id": test_agent.id,
|
||||
"customer_id": test_customer.id,
|
||||
"session_id": test_session.session_id,
|
||||
"message": "terrible awful horrible worst",
|
||||
"message_type": "user"
|
||||
})
|
||||
db.refresh(test_session)
|
||||
# With one positive and one negative, session should be near neutral
|
||||
assert test_session.sentiment_label is not None
|
||||
assert test_session.sentiment_score is not None
|
||||
|
||||
@patch("app.repositories.chat.analyze_sentiment", side_effect=Exception("analysis failed"))
|
||||
def test_sentiment_error_does_not_break_message_creation(self, mock_analyze, chat_repo, test_organization, test_agent, test_customer, test_session):
|
||||
"""Sentiment analysis errors should not prevent message creation."""
|
||||
message = chat_repo.create_message({
|
||||
"organization_id": test_organization.id,
|
||||
"agent_id": test_agent.id,
|
||||
"customer_id": test_customer.id,
|
||||
"session_id": test_session.session_id,
|
||||
"message": "Hello there",
|
||||
"message_type": "user"
|
||||
})
|
||||
assert message is not None
|
||||
assert message.message == "Hello there"
|
||||
# Sentiment should be None since analysis failed
|
||||
assert message.sentiment_label is None
|
||||
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
"""Who can see which chats.
|
||||
|
||||
The inbox filter and the single-session access check must agree: a session that
|
||||
appears in the list and 404s when opened is the bug this pins down.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.chat_history import ChatHistory
|
||||
from app.models.customer import Customer
|
||||
from app.models.session_to_agent import SessionToAgent
|
||||
from app.models.user import User, UserGroup
|
||||
from app.repositories.chat import ChatRepository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def customer(db: Session, test_organization_id: UUID) -> Customer:
|
||||
customer = Customer(
|
||||
id=uuid4(),
|
||||
email="visibility@example.com",
|
||||
full_name="Visibility Customer",
|
||||
organization_id=test_organization_id,
|
||||
)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
return customer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def group(db: Session, test_organization_id: UUID) -> UserGroup:
|
||||
group = UserGroup(id=uuid4(), name="Support", organization_id=test_organization_id)
|
||||
db.add(group)
|
||||
db.commit()
|
||||
return group
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent_user(db: Session, test_organization_id: UUID, group: UserGroup) -> User:
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
email="agent@example.com",
|
||||
hashed_password="x",
|
||||
full_name="Agent",
|
||||
organization_id=test_organization_id,
|
||||
is_active=True,
|
||||
)
|
||||
user.groups.append(group)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def _session(db, org_id, agent_id, customer_id, *, user_id=None, group_id=None):
|
||||
"""A session plus one message, so it shows up in the grouped overview."""
|
||||
session = SessionToAgent(
|
||||
session_id=uuid4(),
|
||||
organization_id=org_id,
|
||||
agent_id=agent_id,
|
||||
customer_id=customer_id,
|
||||
user_id=user_id,
|
||||
group_id=group_id,
|
||||
)
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.add(ChatHistory(
|
||||
message="hello",
|
||||
message_type="user",
|
||||
session_id=session.session_id,
|
||||
organization_id=org_id,
|
||||
agent_id=agent_id,
|
||||
customer_id=customer_id,
|
||||
))
|
||||
db.commit()
|
||||
return session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sessions(db, test_organization_id, test_agent, customer, agent_user, group):
|
||||
"""One session of each ownership kind."""
|
||||
other_user = User(
|
||||
id=uuid4(), email="other@example.com", hashed_password="x",
|
||||
full_name="Other", organization_id=test_organization_id, is_active=True,
|
||||
)
|
||||
db.add(other_user)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"mine": _session(db, test_organization_id, test_agent.id, customer.id, user_id=agent_user.id),
|
||||
"my_group": _session(db, test_organization_id, test_agent.id, customer.id, group_id=group.id),
|
||||
"unassigned": _session(db, test_organization_id, test_agent.id, customer.id),
|
||||
"other_agents": _session(db, test_organization_id, test_agent.id, customer.id, user_id=other_user.id),
|
||||
}
|
||||
|
||||
|
||||
def _ids(rows):
|
||||
return {str(r["session_id"]) for r in rows}
|
||||
|
||||
|
||||
def test_assigned_only_excludes_the_ai_queue(db, test_organization_id, agent_user, group, sessions):
|
||||
"""Without view_unassigned_chats an agent sees only their own and their group's."""
|
||||
rows = ChatRepository(db).get_recent_chats(
|
||||
organization_id=test_organization_id,
|
||||
user_id=agent_user.id,
|
||||
user_groups=[str(group.id)],
|
||||
include_unassigned=False,
|
||||
)
|
||||
|
||||
assert str(sessions["mine"].session_id) in _ids(rows)
|
||||
assert str(sessions["my_group"].session_id) in _ids(rows)
|
||||
assert str(sessions["unassigned"].session_id) not in _ids(rows)
|
||||
assert str(sessions["other_agents"].session_id) not in _ids(rows)
|
||||
|
||||
|
||||
def test_unassigned_grant_adds_the_ai_queue_but_not_other_agents(
|
||||
db, test_organization_id, agent_user, group, sessions
|
||||
):
|
||||
"""The queue becomes visible; another agent's conversation stays private."""
|
||||
rows = ChatRepository(db).get_recent_chats(
|
||||
organization_id=test_organization_id,
|
||||
user_id=agent_user.id,
|
||||
user_groups=[str(group.id)],
|
||||
include_unassigned=True,
|
||||
)
|
||||
|
||||
assert str(sessions["unassigned"].session_id) in _ids(rows)
|
||||
assert str(sessions["mine"].session_id) in _ids(rows)
|
||||
assert str(sessions["my_group"].session_id) in _ids(rows)
|
||||
assert str(sessions["other_agents"].session_id) not in _ids(rows)
|
||||
|
||||
|
||||
def test_queue_only_role_sees_every_unclaimed_chat_and_nothing_owned(
|
||||
db, test_organization_id, sessions
|
||||
):
|
||||
"""view_unassigned_chats alone: everything nobody holds, nothing owned.
|
||||
|
||||
A chat transferred to a group is still unclaimed, so it belongs to the
|
||||
queue — that is the point of the grant, not an accident of the filter.
|
||||
"""
|
||||
rows = ChatRepository(db).get_recent_chats(
|
||||
organization_id=test_organization_id,
|
||||
user_id=None,
|
||||
user_groups=None,
|
||||
include_unassigned=True,
|
||||
)
|
||||
|
||||
assert _ids(rows) == {
|
||||
str(sessions["unassigned"].session_id),
|
||||
str(sessions["my_group"].session_id),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_access_matches_the_list_filter(
|
||||
db, agent_user, group, sessions
|
||||
):
|
||||
"""check_session_access must mirror get_recent_chats, or a listed chat 404s."""
|
||||
repo = ChatRepository(db)
|
||||
groups = [str(group.id)]
|
||||
|
||||
# Without the queue grant
|
||||
assert await repo.check_session_access(sessions["mine"].session_id, agent_user.id, groups) is True
|
||||
assert await repo.check_session_access(sessions["my_group"].session_id, agent_user.id, groups) is True
|
||||
assert await repo.check_session_access(sessions["unassigned"].session_id, agent_user.id, groups) is False
|
||||
assert await repo.check_session_access(sessions["other_agents"].session_id, agent_user.id, groups) is False
|
||||
|
||||
# With it, only the unclaimed one opens up
|
||||
assert await repo.check_session_access(
|
||||
sessions["unassigned"].session_id, agent_user.id, groups, include_unassigned=True) is True
|
||||
assert await repo.check_session_access(
|
||||
sessions["other_agents"].session_id, agent_user.id, groups, include_unassigned=True) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_user_id_never_matches_an_unassigned_session(db, sessions):
|
||||
"""A caller with no assigned-chat grant passes user_id=None; NULL == NULL
|
||||
must not hand them every unclaimed session by accident."""
|
||||
repo = ChatRepository(db)
|
||||
|
||||
assert await repo.check_session_access(
|
||||
sessions["unassigned"].session_id, None, [], include_unassigned=False) is False
|
||||
assert await repo.check_session_access(
|
||||
sessions["unassigned"].session_id, None, [], include_unassigned=True) is True
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.repositories.customer import CustomerRepository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(db):
|
||||
return CustomerRepository(db)
|
||||
|
||||
|
||||
class TestIdentify:
|
||||
"""The shared upsert behind /generate-token and the hosted support chat: an
|
||||
embedding app naming someone it already authenticated."""
|
||||
|
||||
def test_creates_an_authenticated_customer(self, repo, test_organization):
|
||||
customer = repo.identify(
|
||||
email="signed.in@example.com",
|
||||
organization_id=test_organization.id,
|
||||
full_name="Signed In",
|
||||
)
|
||||
|
||||
assert customer.email == "signed.in@example.com"
|
||||
assert customer.full_name == "Signed In"
|
||||
# What keeps the business's own users out of the People/leads views
|
||||
assert customer.is_authenticated is True
|
||||
|
||||
def test_returns_the_same_customer_on_the_next_call(self, repo, test_organization):
|
||||
first = repo.identify(email="repeat@example.com", organization_id=test_organization.id)
|
||||
second = repo.identify(email="repeat@example.com", organization_id=test_organization.id)
|
||||
|
||||
assert first.id == second.id
|
||||
|
||||
def test_marks_a_previously_anonymous_customer_as_identified(self, repo, test_organization):
|
||||
anonymous = repo.create_customer(
|
||||
email="was.anonymous@example.com", organization_id=test_organization.id
|
||||
)
|
||||
assert anonymous.is_authenticated is False
|
||||
|
||||
identified = repo.identify(
|
||||
email="was.anonymous@example.com",
|
||||
organization_id=test_organization.id,
|
||||
full_name="Now Known",
|
||||
)
|
||||
|
||||
assert identified.id == anonymous.id
|
||||
assert identified.is_authenticated is True
|
||||
assert identified.full_name == "Now Known"
|
||||
|
||||
def test_merges_meta_data_and_keeps_untouched_keys(self, repo, test_organization):
|
||||
repo.identify(
|
||||
email="meta@example.com",
|
||||
organization_id=test_organization.id,
|
||||
meta_data={"plan": "pro", "seat_count": 3},
|
||||
)
|
||||
customer = repo.identify(
|
||||
email="meta@example.com",
|
||||
organization_id=test_organization.id,
|
||||
meta_data={"plan": "enterprise"},
|
||||
)
|
||||
|
||||
assert customer.meta_data == {"plan": "enterprise", "seat_count": 3}
|
||||
|
||||
def test_keeps_the_stored_name_when_none_is_supplied(self, repo, test_organization):
|
||||
repo.identify(
|
||||
email="named@example.com",
|
||||
organization_id=test_organization.id,
|
||||
full_name="Original Name",
|
||||
)
|
||||
customer = repo.identify(email="named@example.com", organization_id=test_organization.id)
|
||||
|
||||
assert customer.full_name == "Original Name"
|
||||
|
||||
def test_scopes_the_lookup_to_the_organization(self, repo, db, test_organization):
|
||||
from app.models.organization import Organization
|
||||
|
||||
other_org = Organization(name="Other Org", domain="other.example")
|
||||
db.add(other_org)
|
||||
db.commit()
|
||||
db.refresh(other_org)
|
||||
|
||||
mine = repo.identify(email="shared@example.com", organization_id=test_organization.id)
|
||||
theirs = repo.identify(email="shared@example.com", organization_id=other_org.id)
|
||||
|
||||
assert mine.id != theirs.id
|
||||
|
||||
def test_survives_another_caller_creating_the_same_person_first(
|
||||
self, repo, db, test_organization
|
||||
):
|
||||
"""Two dashboard tabs opening the chat at once both read "no such
|
||||
customer" and both insert; the unique constraint fails one of them."""
|
||||
winner = repo.create_customer(
|
||||
email="race@example.com", organization_id=test_organization.id
|
||||
)
|
||||
|
||||
real_lookup = repo.get_customer_by_email
|
||||
calls = {"n": 0}
|
||||
|
||||
def lookup_blind_once(email, organization_id):
|
||||
# First read happens before the other caller commits, so it sees nothing.
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
return None
|
||||
return real_lookup(email, organization_id)
|
||||
|
||||
repo.get_customer_by_email = lookup_blind_once
|
||||
|
||||
customer = repo.identify(
|
||||
email="race@example.com",
|
||||
organization_id=test_organization.id,
|
||||
full_name="Raced In",
|
||||
)
|
||||
|
||||
assert customer.id == winner.id
|
||||
assert customer.is_authenticated is True
|
||||
assert customer.full_name == "Raced In"
|
||||
|
||||
def test_does_not_rewrite_meta_data_that_has_not_changed(self, repo, test_organization):
|
||||
"""The token endpoint calls this on every request; restoring what is
|
||||
already stored should not cost a write."""
|
||||
meta = {"dashboard_organization_id": "org-1"}
|
||||
repo.identify(
|
||||
email="steady@example.com", organization_id=test_organization.id, meta_data=meta
|
||||
)
|
||||
|
||||
writes = []
|
||||
real_update = repo.update_meta_data
|
||||
repo.update_meta_data = lambda cid, m: (writes.append(m), real_update(cid, m))[1]
|
||||
|
||||
repo.identify(
|
||||
email="steady@example.com", organization_id=test_organization.id, meta_data=meta
|
||||
)
|
||||
assert writes == []
|
||||
|
||||
# ...but a changed value still lands
|
||||
repo.identify(
|
||||
email="steady@example.com",
|
||||
organization_id=test_organization.id,
|
||||
meta_data={"dashboard_organization_id": "org-2"},
|
||||
)
|
||||
assert writes == [{"dashboard_organization_id": "org-2"}]
|
||||
|
||||
def test_refuses_a_blank_email(self, repo, test_organization):
|
||||
with pytest.raises(ValueError):
|
||||
repo.identify(email=" ", organization_id=test_organization.id)
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from uuid import uuid4
|
||||
|
||||
from app.models.fcm_token import FCMToken
|
||||
from app.models.user import User
|
||||
from app.repositories.fcm_token import FCMTokenRepository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(db):
|
||||
return FCMTokenRepository(db)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def other_user(db, test_organization, test_role) -> User:
|
||||
user = User(
|
||||
email="other@example.com",
|
||||
hashed_password="x",
|
||||
full_name="Other User",
|
||||
organization_id=test_organization.id,
|
||||
role_id=test_role.id,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
class TestRegister:
|
||||
def test_stores_token(self, repo, test_user):
|
||||
assert repo.register(test_user.id, "phone_token") is True
|
||||
assert repo.get_tokens(test_user.id) == ["phone_token"]
|
||||
|
||||
def test_is_idempotent(self, repo, test_user):
|
||||
"""The client posts on every app load — that must not create duplicates"""
|
||||
for _ in range(3):
|
||||
repo.register(test_user.id, "phone_token")
|
||||
assert repo.get_tokens(test_user.id) == ["phone_token"]
|
||||
|
||||
def test_keeps_other_devices(self, repo, test_user):
|
||||
repo.register(test_user.id, "phone_token")
|
||||
repo.register(test_user.id, "laptop_token")
|
||||
assert set(repo.get_tokens(test_user.id)) == {"phone_token", "laptop_token"}
|
||||
|
||||
def test_survives_a_lost_insert_race(self, repo, test_user, db, monkeypatch):
|
||||
"""Two tabs post the same token at once; the loser must still succeed.
|
||||
|
||||
The client posts on every app load, so a concurrent duplicate insert is
|
||||
routine — failing it would leave that browser unregistered.
|
||||
"""
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
real_commit = db.commit
|
||||
calls = {"n": 0}
|
||||
|
||||
def commit_once_conflicting():
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
# Simulate the other tab having committed the row first.
|
||||
raise IntegrityError("duplicate", None, Exception("conflict"))
|
||||
return real_commit()
|
||||
|
||||
monkeypatch.setattr(db, "commit", commit_once_conflicting)
|
||||
assert repo.register(test_user.id, "raced_token") is True
|
||||
monkeypatch.undo()
|
||||
|
||||
assert repo.get_tokens(test_user.id) == ["raced_token"]
|
||||
|
||||
def test_reassigns_shared_browser(self, repo, test_user, other_user):
|
||||
"""FCM hands the same token to a browser whichever account signs in, so
|
||||
the row moves rather than delivering to the previous owner."""
|
||||
repo.register(test_user.id, "shared_browser_token")
|
||||
repo.register(other_user.id, "shared_browser_token")
|
||||
|
||||
assert repo.get_tokens(test_user.id) == []
|
||||
assert repo.get_tokens(other_user.id) == ["shared_browser_token"]
|
||||
|
||||
|
||||
class TestGetTokens:
|
||||
def test_empty_for_unknown_user(self, repo):
|
||||
assert repo.get_tokens(uuid4()) == []
|
||||
|
||||
|
||||
class TestRemove:
|
||||
def test_removes_only_that_device(self, repo, test_user):
|
||||
repo.register(test_user.id, "phone_token")
|
||||
repo.register(test_user.id, "laptop_token")
|
||||
|
||||
assert repo.remove(test_user.id, "laptop_token") is True
|
||||
assert repo.get_tokens(test_user.id) == ["phone_token"]
|
||||
|
||||
def test_unknown_token_is_not_an_error(self, repo, test_user):
|
||||
assert repo.remove(test_user.id, "never_registered") is False
|
||||
|
||||
def test_cannot_remove_another_users_token(self, repo, test_user, other_user):
|
||||
repo.register(other_user.id, "their_token")
|
||||
|
||||
assert repo.remove(test_user.id, "their_token") is False
|
||||
assert repo.get_tokens(other_user.id) == ["their_token"]
|
||||
|
||||
|
||||
class TestRemoveTokens:
|
||||
def test_prunes_listed_tokens(self, repo, test_user):
|
||||
repo.register(test_user.id, "live")
|
||||
repo.register(test_user.id, "dead_one")
|
||||
repo.register(test_user.id, "dead_two")
|
||||
|
||||
assert repo.remove_tokens(["dead_one", "dead_two"]) == 2
|
||||
assert repo.get_tokens(test_user.id) == ["live"]
|
||||
|
||||
def test_empty_list_is_a_no_op(self, repo, test_user):
|
||||
repo.register(test_user.id, "live")
|
||||
assert repo.remove_tokens([]) == 0
|
||||
assert repo.get_tokens(test_user.id) == ["live"]
|
||||
|
||||
|
||||
class TestCascade:
|
||||
def test_tokens_die_with_the_user(self, db, repo, other_user):
|
||||
repo.register(other_user.id, "doomed_token")
|
||||
|
||||
db.delete(other_user)
|
||||
db.commit()
|
||||
|
||||
assert db.query(FCMToken).filter(
|
||||
FCMToken.token == "doomed_token").first() is None
|
||||
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.guardrail_event import GuardrailEvent
|
||||
from app.repositories.guardrail_event import (
|
||||
GuardrailEventRepository,
|
||||
record_guardrail_events,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_session(db):
|
||||
"""record_guardrail_event opens its own SessionLocal — point it at the
|
||||
test session."""
|
||||
with patch("app.repositories.guardrail_event.SessionLocal") as mock_sl:
|
||||
mock_sl.return_value.__enter__.return_value = db
|
||||
mock_sl.return_value.__exit__.return_value = False
|
||||
yield db
|
||||
|
||||
|
||||
def _add_event(db, rule="injection.frame_tokens", created_at=None, org_id=None):
|
||||
event = GuardrailEvent(
|
||||
organization_id=org_id,
|
||||
surface="widget",
|
||||
layer="inbound",
|
||||
rule=rule,
|
||||
action="counted",
|
||||
)
|
||||
if created_at is not None:
|
||||
event.created_at = created_at
|
||||
db.add(event)
|
||||
db.commit()
|
||||
return event
|
||||
|
||||
|
||||
class TestRecord:
|
||||
def test_record_persists_event(self, patched_session):
|
||||
record_guardrail_events(
|
||||
rules=["injection.override_instructions"],
|
||||
surface="widget",
|
||||
layer="inbound",
|
||||
action="counted",
|
||||
score=1.0,
|
||||
char_len=42,
|
||||
)
|
||||
rows = patched_session.query(GuardrailEvent).all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].rule == "injection.override_instructions"
|
||||
assert rows[0].action == "counted"
|
||||
assert rows[0].char_len == 42
|
||||
|
||||
def test_record_never_raises(self):
|
||||
with patch(
|
||||
"app.repositories.guardrail_event.SessionLocal",
|
||||
side_effect=RuntimeError("db down"),
|
||||
):
|
||||
# Must swallow: a failed event write cannot affect the reply.
|
||||
record_guardrail_events(
|
||||
rules=["x"], surface="widget", layer="inbound", action="counted"
|
||||
)
|
||||
|
||||
def test_matched_holds_rule_ids_only(self, patched_session):
|
||||
# matched is plain JSON while excerpt is encrypted, so the matched
|
||||
# SUBSTRING (the visitor's own words) must never land here.
|
||||
record_guardrail_events(
|
||||
rules=["injection.frame_tokens", "injection.role_hijack"],
|
||||
surface="widget",
|
||||
layer="inbound",
|
||||
action="blocked",
|
||||
)
|
||||
rows = patched_session.query(GuardrailEvent).all()
|
||||
assert len(rows) == 2, "one row per triggered rule"
|
||||
for row in rows:
|
||||
assert json.dumps(row.matched) == json.dumps(
|
||||
["injection.frame_tokens", "injection.role_hijack"]
|
||||
)
|
||||
|
||||
def test_multiple_rules_share_one_session(self, patched_session):
|
||||
record_guardrail_events(
|
||||
rules=["a", "b", "c"], surface="widget", layer="inbound", action="counted"
|
||||
)
|
||||
assert patched_session.query(GuardrailEvent).count() == 3
|
||||
|
||||
def test_empty_rules_writes_nothing(self, patched_session):
|
||||
record_guardrail_events(
|
||||
rules=[], surface="widget", layer="inbound", action="counted"
|
||||
)
|
||||
assert patched_session.query(GuardrailEvent).count() == 0
|
||||
|
||||
def test_excerpt_nullable(self, patched_session):
|
||||
record_guardrail_events(
|
||||
rules=["x"], surface="help_center", layer="inbound", action="counted"
|
||||
)
|
||||
assert patched_session.query(GuardrailEvent).one().excerpt is None
|
||||
|
||||
|
||||
class TestCountsByRule:
|
||||
def test_aggregates_per_rule_in_window(self, db):
|
||||
now = datetime.now(timezone.utc)
|
||||
_add_event(db, rule="injection.frame_tokens", created_at=now)
|
||||
_add_event(db, rule="injection.frame_tokens", created_at=now)
|
||||
_add_event(db, rule="offtopic.model_refused", created_at=now)
|
||||
# Outside the window on both sides.
|
||||
_add_event(db, rule="injection.frame_tokens", created_at=now - timedelta(days=10))
|
||||
_add_event(db, rule="injection.frame_tokens", created_at=now + timedelta(days=10))
|
||||
|
||||
counts = GuardrailEventRepository(db).counts_by_rule(
|
||||
start=now - timedelta(days=1), end=now + timedelta(days=1)
|
||||
)
|
||||
assert counts == {
|
||||
"injection.frame_tokens": 2,
|
||||
"offtopic.model_refused": 1,
|
||||
}
|
||||
|
||||
def test_org_filter(self, db, test_organization):
|
||||
now = datetime.now(timezone.utc)
|
||||
_add_event(db, created_at=now, org_id=test_organization.id)
|
||||
_add_event(db, created_at=now, org_id=None)
|
||||
counts = GuardrailEventRepository(db).counts_by_rule(
|
||||
start=now - timedelta(days=1),
|
||||
end=now + timedelta(days=1),
|
||||
organization_id=test_organization.id,
|
||||
)
|
||||
assert counts == {"injection.frame_tokens": 1}
|
||||
|
||||
|
||||
class TestReview:
|
||||
def test_mark_false_positive(self, db):
|
||||
event = _add_event(db)
|
||||
assert event.reviewed is False
|
||||
updated = GuardrailEventRepository(db).mark_false_positive(event.id)
|
||||
assert updated.reviewed is True
|
||||
assert updated.false_positive is True
|
||||
|
||||
def test_mark_false_positive_missing_event(self, db):
|
||||
import uuid
|
||||
|
||||
assert GuardrailEventRepository(db).mark_false_positive(uuid.uuid4()) is None
|
||||
@@ -0,0 +1,548 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
from uuid import UUID, uuid4
|
||||
from unittest.mock import patch, MagicMock
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.models.user import User
|
||||
from app.models.role import Role
|
||||
from app.models.permission import Permission, role_permissions
|
||||
from app.models.agent import Agent, AgentType
|
||||
from app.models.jira import AgentJiraConfig, JiraToken
|
||||
from app.models.organization import Organization
|
||||
from app.models.user import UserGroup
|
||||
from app.core.security import get_password_hash
|
||||
from app.repositories.jira import JiraRepository
|
||||
from app.models.schemas.jira import AgentWithJiraConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_role(db: Session, test_organization_id: UUID) -> Role:
|
||||
"""Create a test role with required permissions"""
|
||||
role = Role(
|
||||
name="Test Role",
|
||||
organization_id=test_organization_id
|
||||
)
|
||||
db.add(role)
|
||||
db.commit()
|
||||
|
||||
# Add required permissions
|
||||
permission = Permission(
|
||||
name="manage_jira",
|
||||
description="Can manage Jira integration"
|
||||
)
|
||||
db.add(permission)
|
||||
db.commit()
|
||||
|
||||
# Associate permission with role
|
||||
db.execute(
|
||||
role_permissions.insert().values(
|
||||
role_id=role.id,
|
||||
permission_id=permission.id
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return role
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user(db: Session, test_organization_id: UUID, test_role: Role) -> User:
|
||||
"""Create a test user with required permissions"""
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
email="test@test.com",
|
||||
hashed_password=get_password_hash("testpassword"),
|
||||
organization_id=test_organization_id,
|
||||
role_id=test_role.id,
|
||||
is_active=True
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user_group(db: Session, test_organization_id: UUID) -> UserGroup:
|
||||
"""Create a test user group"""
|
||||
group = UserGroup(
|
||||
id=uuid4(),
|
||||
name="Test Group",
|
||||
organization_id=test_organization_id,
|
||||
description="Test group for Jira tests"
|
||||
)
|
||||
db.add(group)
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
return group
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_agent(db: Session, test_organization_id: UUID, test_organization, test_user_group: UserGroup) -> Agent:
|
||||
"""Create a test agent"""
|
||||
agent = Agent(
|
||||
id=uuid4(),
|
||||
organization_id=test_organization_id,
|
||||
name="Test Agent",
|
||||
display_name="Test Agent Display",
|
||||
description="Test agent description",
|
||||
instructions="Test instructions",
|
||||
tools='["jira"]',
|
||||
agent_type=AgentType.CUSTOMER_SUPPORT,
|
||||
is_default=False,
|
||||
is_active=True,
|
||||
transfer_to_human=False,
|
||||
ask_for_rating=True
|
||||
)
|
||||
db.add(agent)
|
||||
db.commit()
|
||||
db.refresh(agent)
|
||||
|
||||
# Set up relationships manually since they might not be auto-loaded
|
||||
agent.organization = test_organization
|
||||
agent.groups = [test_user_group]
|
||||
|
||||
return agent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_agent_jira_config(db: Session, test_agent: Agent) -> AgentJiraConfig:
|
||||
"""Create a test agent Jira configuration"""
|
||||
config = AgentJiraConfig(
|
||||
agent_id=str(test_agent.id),
|
||||
enabled=True,
|
||||
project_key="TEST",
|
||||
issue_type_id="10001"
|
||||
)
|
||||
db.add(config)
|
||||
db.commit()
|
||||
db.refresh(config)
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_jira_token(db: Session, test_organization_id: UUID) -> JiraToken:
|
||||
"""Create a test Jira token"""
|
||||
token = JiraToken(
|
||||
organization_id=test_organization_id,
|
||||
access_token="test_access_token",
|
||||
refresh_token="test_refresh_token",
|
||||
token_type="Bearer",
|
||||
expires_at=datetime.utcnow(),
|
||||
cloud_id="test_cloud_id",
|
||||
site_url="https://test.atlassian.net"
|
||||
)
|
||||
db.add(token)
|
||||
db.commit()
|
||||
db.refresh(token)
|
||||
return token
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jira_repo(db):
|
||||
"""Create a Jira repository instance"""
|
||||
return JiraRepository(db)
|
||||
|
||||
|
||||
class TestJiraRepository:
|
||||
"""Test Jira repository functionality"""
|
||||
|
||||
def test_get_agent_with_jira_config_success(self, jira_repo, test_agent, test_agent_jira_config):
|
||||
"""Test successfully retrieving agent with Jira configuration"""
|
||||
result = jira_repo.get_agent_with_jira_config(str(test_agent.id))
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, AgentWithJiraConfig)
|
||||
assert result.id == test_agent.id
|
||||
assert result.name == test_agent.name
|
||||
assert result.display_name == test_agent.display_name
|
||||
assert result.description == test_agent.description
|
||||
assert result.instructions == test_agent.instructions
|
||||
assert result.tools == ["jira"] # Parsed from JSON string
|
||||
assert result.agent_type == test_agent.agent_type
|
||||
assert result.is_default == test_agent.is_default
|
||||
assert result.is_active == test_agent.is_active
|
||||
assert result.organization_id == test_agent.organization_id
|
||||
assert result.transfer_to_human == test_agent.transfer_to_human
|
||||
assert result.ask_for_rating == test_agent.ask_for_rating
|
||||
|
||||
# Check Jira-specific fields
|
||||
assert result.jira_enabled is True
|
||||
assert result.jira_project_key == "TEST"
|
||||
assert result.jira_issue_type_id == "10001"
|
||||
|
||||
def test_get_agent_with_jira_config_uuid_input(self, jira_repo, test_agent, test_agent_jira_config):
|
||||
"""Test retrieving agent with UUID input"""
|
||||
result = jira_repo.get_agent_with_jira_config(test_agent.id)
|
||||
|
||||
assert result is not None
|
||||
assert result.id == test_agent.id
|
||||
assert result.jira_enabled is True
|
||||
|
||||
def test_get_agent_with_jira_config_no_jira_config(self, jira_repo, test_agent):
|
||||
"""Test retrieving agent without Jira configuration"""
|
||||
result = jira_repo.get_agent_with_jira_config(str(test_agent.id))
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, AgentWithJiraConfig)
|
||||
assert result.id == test_agent.id
|
||||
assert result.name == test_agent.name
|
||||
|
||||
# Check default Jira fields
|
||||
assert result.jira_enabled is False
|
||||
assert result.jira_project_key is None
|
||||
assert result.jira_issue_type_id is None
|
||||
|
||||
def test_get_agent_with_jira_config_nonexistent_agent(self, jira_repo):
|
||||
"""Test retrieving nonexistent agent"""
|
||||
result = jira_repo.get_agent_with_jira_config(str(uuid4()))
|
||||
assert result is None
|
||||
|
||||
def test_get_agent_with_jira_config_invalid_uuid(self, jira_repo):
|
||||
"""Test retrieving agent with invalid UUID string"""
|
||||
result = jira_repo.get_agent_with_jira_config("invalid-uuid")
|
||||
assert result is None
|
||||
|
||||
def test_get_agent_with_jira_config_empty_string(self, jira_repo):
|
||||
"""Test retrieving agent with empty string"""
|
||||
result = jira_repo.get_agent_with_jira_config("")
|
||||
assert result is None
|
||||
|
||||
def test_get_agent_with_jira_config_none_input(self, jira_repo):
|
||||
"""Test retrieving agent with None input"""
|
||||
result = jira_repo.get_agent_with_jira_config(None)
|
||||
assert result is None
|
||||
|
||||
@patch('app.repositories.jira.logger')
|
||||
def test_get_agent_with_jira_config_database_error(self, mock_logger, jira_repo):
|
||||
"""Test agent retrieval with database error"""
|
||||
with patch.object(jira_repo.db, 'query', side_effect=SQLAlchemyError("DB Error")):
|
||||
result = jira_repo.get_agent_with_jira_config(str(uuid4()))
|
||||
assert result is None
|
||||
mock_logger.error.assert_called()
|
||||
|
||||
@patch('app.repositories.jira.logger')
|
||||
def test_get_agent_with_jira_config_uuid_conversion_error(self, mock_logger, jira_repo):
|
||||
"""Test agent retrieval with UUID conversion error"""
|
||||
result = jira_repo.get_agent_with_jira_config("not-a-valid-uuid")
|
||||
assert result is None
|
||||
mock_logger.error.assert_called()
|
||||
|
||||
def test_get_agent_with_jira_config_disabled_jira(self, jira_repo, test_agent, db):
|
||||
"""Test retrieving agent with disabled Jira configuration"""
|
||||
# Create disabled Jira config
|
||||
config = AgentJiraConfig(
|
||||
agent_id=str(test_agent.id),
|
||||
enabled=False,
|
||||
project_key="TEST",
|
||||
issue_type_id="10001"
|
||||
)
|
||||
db.add(config)
|
||||
db.commit()
|
||||
|
||||
result = jira_repo.get_agent_with_jira_config(str(test_agent.id))
|
||||
|
||||
assert result is not None
|
||||
assert result.jira_enabled is False
|
||||
assert result.jira_project_key == "TEST" # Still populated even if disabled
|
||||
assert result.jira_issue_type_id == "10001"
|
||||
|
||||
def test_get_agent_with_jira_config_partial_jira_config(self, jira_repo, test_agent, db):
|
||||
"""Test retrieving agent with partial Jira configuration"""
|
||||
# Create config with only some fields
|
||||
config = AgentJiraConfig(
|
||||
agent_id=str(test_agent.id),
|
||||
enabled=True,
|
||||
project_key="TEST",
|
||||
issue_type_id=None # Missing issue type
|
||||
)
|
||||
db.add(config)
|
||||
db.commit()
|
||||
|
||||
result = jira_repo.get_agent_with_jira_config(str(test_agent.id))
|
||||
|
||||
assert result is not None
|
||||
assert result.jira_enabled is True
|
||||
assert result.jira_project_key == "TEST"
|
||||
assert result.jira_issue_type_id is None
|
||||
|
||||
def test_get_agent_with_jira_config_relationships_loaded(self, jira_repo, test_agent, test_agent_jira_config):
|
||||
"""Test that agent relationships are properly loaded"""
|
||||
result = jira_repo.get_agent_with_jira_config(str(test_agent.id))
|
||||
|
||||
assert result is not None
|
||||
# Check that relationships are included (even if empty lists)
|
||||
assert hasattr(result, 'groups')
|
||||
assert hasattr(result, 'organization')
|
||||
assert hasattr(result, 'knowledge')
|
||||
assert result.knowledge == [] # Default empty list
|
||||
|
||||
def test_get_agent_with_jira_config_multiple_configs(self, jira_repo, test_agent, db):
|
||||
"""Test retrieving agent when multiple Jira configs exist (should get first)"""
|
||||
# Create multiple configs (this shouldn't happen in practice, but test edge case)
|
||||
config1 = AgentJiraConfig(
|
||||
agent_id=str(test_agent.id),
|
||||
enabled=True,
|
||||
project_key="TEST1",
|
||||
issue_type_id="10001"
|
||||
)
|
||||
config2 = AgentJiraConfig(
|
||||
agent_id=str(test_agent.id),
|
||||
enabled=False,
|
||||
project_key="TEST2",
|
||||
issue_type_id="10002"
|
||||
)
|
||||
db.add(config1)
|
||||
db.add(config2)
|
||||
db.commit()
|
||||
|
||||
result = jira_repo.get_agent_with_jira_config(str(test_agent.id))
|
||||
|
||||
assert result is not None
|
||||
# Should get the first config found
|
||||
assert result.jira_enabled is True
|
||||
assert result.jira_project_key == "TEST1"
|
||||
assert result.jira_issue_type_id == "10001"
|
||||
|
||||
def test_get_agent_with_jira_config_string_agent_id_in_config(self, jira_repo, test_agent, db):
|
||||
"""Test that agent_id is properly converted to string for Jira config lookup"""
|
||||
# Create config with string agent_id
|
||||
config = AgentJiraConfig(
|
||||
agent_id=str(test_agent.id), # Explicitly string
|
||||
enabled=True,
|
||||
project_key="TEST",
|
||||
issue_type_id="10001"
|
||||
)
|
||||
db.add(config)
|
||||
db.commit()
|
||||
|
||||
# Test with both string and UUID inputs
|
||||
result1 = jira_repo.get_agent_with_jira_config(str(test_agent.id))
|
||||
result2 = jira_repo.get_agent_with_jira_config(test_agent.id)
|
||||
|
||||
assert result1 is not None
|
||||
assert result2 is not None
|
||||
assert result1.jira_enabled is True
|
||||
assert result2.jira_enabled is True
|
||||
|
||||
@patch('app.repositories.jira.logger')
|
||||
def test_get_agent_with_jira_config_exception_in_agent_data_creation(self, mock_logger, jira_repo, test_agent, db):
|
||||
"""Test exception handling during AgentWithJiraConfig creation"""
|
||||
# Create a mock agent that will cause an exception during AgentWithJiraConfig creation
|
||||
with patch('app.repositories.jira.AgentWithJiraConfig', side_effect=Exception("Schema Error")):
|
||||
result = jira_repo.get_agent_with_jira_config(str(test_agent.id))
|
||||
assert result is None
|
||||
mock_logger.error.assert_called()
|
||||
|
||||
def test_get_agent_with_jira_config_all_agent_fields_mapped(self, jira_repo, test_agent, test_agent_jira_config):
|
||||
"""Test that all agent fields are properly mapped to AgentWithJiraConfig"""
|
||||
result = jira_repo.get_agent_with_jira_config(str(test_agent.id))
|
||||
|
||||
assert result is not None
|
||||
|
||||
# Verify all agent fields are mapped
|
||||
agent_fields = [
|
||||
'id', 'name', 'display_name', 'description', 'instructions',
|
||||
'agent_type', 'is_default', 'is_active',
|
||||
'organization_id', 'transfer_to_human', 'ask_for_rating'
|
||||
]
|
||||
|
||||
for field in agent_fields:
|
||||
assert hasattr(result, field)
|
||||
assert getattr(result, field) == getattr(test_agent, field)
|
||||
|
||||
# Special check for tools field (parsed from JSON)
|
||||
assert hasattr(result, 'tools')
|
||||
assert result.tools == ["jira"] # Parsed from JSON string
|
||||
|
||||
def test_get_agent_with_jira_config_jira_fields_default_values(self, jira_repo, test_agent):
|
||||
"""Test that Jira fields have correct default values when no config exists"""
|
||||
result = jira_repo.get_agent_with_jira_config(str(test_agent.id))
|
||||
|
||||
assert result is not None
|
||||
assert result.jira_enabled is False
|
||||
assert result.jira_project_key is None
|
||||
assert result.jira_issue_type_id is None
|
||||
|
||||
def test_get_agent_with_jira_config_knowledge_field_default(self, jira_repo, test_agent, test_agent_jira_config):
|
||||
"""Test that knowledge field is set to empty list by default"""
|
||||
result = jira_repo.get_agent_with_jira_config(str(test_agent.id))
|
||||
|
||||
assert result is not None
|
||||
assert result.knowledge == []
|
||||
assert isinstance(result.knowledge, list)
|
||||
|
||||
|
||||
class TestJiraRepositoryErrorHandling:
|
||||
"""Test error handling and edge cases"""
|
||||
|
||||
@patch('app.repositories.jira.logger')
|
||||
def test_database_connection_error(self, mock_logger, jira_repo):
|
||||
"""Test handling of database connection errors"""
|
||||
with patch.object(jira_repo.db, 'query', side_effect=SQLAlchemyError("Connection lost")):
|
||||
result = jira_repo.get_agent_with_jira_config(str(uuid4()))
|
||||
assert result is None
|
||||
mock_logger.error.assert_called_with("Error getting agent with Jira config: Connection lost")
|
||||
|
||||
@patch('app.repositories.jira.logger')
|
||||
def test_invalid_uuid_format(self, mock_logger, jira_repo):
|
||||
"""Test handling of various invalid UUID formats"""
|
||||
invalid_uuids = [
|
||||
"not-a-uuid",
|
||||
"12345",
|
||||
"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"123e4567-e89b-12d3-a456-42661417400", # Too short
|
||||
"123e4567-e89b-12d3-a456-426614174000x", # Too long
|
||||
]
|
||||
|
||||
for invalid_uuid in invalid_uuids:
|
||||
result = jira_repo.get_agent_with_jira_config(invalid_uuid)
|
||||
assert result is None
|
||||
|
||||
# Should have logged errors for each invalid UUID
|
||||
assert mock_logger.error.call_count >= len(invalid_uuids)
|
||||
|
||||
def test_empty_and_whitespace_inputs(self, jira_repo):
|
||||
"""Test handling of empty and whitespace-only inputs"""
|
||||
inputs = ["", " ", "\t", "\n", None]
|
||||
|
||||
for input_val in inputs:
|
||||
result = jira_repo.get_agent_with_jira_config(input_val)
|
||||
assert result is None
|
||||
|
||||
@patch('app.repositories.jira.logger')
|
||||
def test_query_timeout_simulation(self, mock_logger, jira_repo):
|
||||
"""Test handling of query timeouts"""
|
||||
with patch.object(jira_repo.db, 'query', side_effect=SQLAlchemyError("Query timeout")):
|
||||
result = jira_repo.get_agent_with_jira_config(str(uuid4()))
|
||||
assert result is None
|
||||
mock_logger.error.assert_called()
|
||||
|
||||
|
||||
class TestJiraRepositoryPerformance:
|
||||
"""Test performance-related aspects"""
|
||||
|
||||
def test_single_query_optimization(self, jira_repo, test_agent, test_agent_jira_config):
|
||||
"""Test that the method uses optimized queries with proper joins"""
|
||||
with patch.object(jira_repo.db, 'query') as mock_query:
|
||||
# Set up the mock chain
|
||||
mock_query_result = MagicMock()
|
||||
mock_query_result.options.return_value.filter.return_value.first.return_value = test_agent
|
||||
mock_query.return_value = mock_query_result
|
||||
|
||||
# Also mock the Jira config query
|
||||
mock_jira_query = MagicMock()
|
||||
mock_jira_query.filter.return_value.first.return_value = test_agent_jira_config
|
||||
|
||||
# Set up side effect to return different mocks for different queries
|
||||
def query_side_effect(model):
|
||||
if model == Agent:
|
||||
return mock_query_result
|
||||
elif model == AgentJiraConfig:
|
||||
return mock_jira_query
|
||||
return MagicMock()
|
||||
|
||||
mock_query.side_effect = query_side_effect
|
||||
|
||||
result = jira_repo.get_agent_with_jira_config(str(test_agent.id))
|
||||
|
||||
# Verify that query was called (optimization check)
|
||||
assert mock_query.called
|
||||
# Verify that joinedload was used for relationships
|
||||
mock_query_result.options.assert_called()
|
||||
|
||||
def test_minimal_data_transfer(self, jira_repo, test_agent, test_agent_jira_config):
|
||||
"""Test that only necessary data is loaded and transferred"""
|
||||
result = jira_repo.get_agent_with_jira_config(str(test_agent.id))
|
||||
|
||||
assert result is not None
|
||||
# Verify that the result contains only the expected fields
|
||||
expected_fields = {
|
||||
'id', 'name', 'display_name', 'description', 'instructions',
|
||||
'tools', 'agent_type', 'is_default', 'is_active', 'organization_id',
|
||||
'transfer_to_human', 'ask_for_rating', 'groups', 'organization',
|
||||
'knowledge', 'jira_enabled', 'jira_project_key', 'jira_issue_type_id'
|
||||
}
|
||||
|
||||
result_dict = result.dict()
|
||||
result_fields = set(result_dict.keys())
|
||||
|
||||
# All expected fields should be present
|
||||
assert expected_fields.issubset(result_fields)
|
||||
|
||||
|
||||
class TestJiraRepositoryDataIntegrity:
|
||||
"""Test data integrity and consistency"""
|
||||
|
||||
def test_agent_data_consistency(self, jira_repo, test_agent, test_agent_jira_config):
|
||||
"""Test that agent data remains consistent between source and result"""
|
||||
result = jira_repo.get_agent_with_jira_config(str(test_agent.id))
|
||||
|
||||
assert result is not None
|
||||
|
||||
# Test all scalar fields for exact match
|
||||
scalar_fields = [
|
||||
'id', 'name', 'display_name', 'description', 'instructions',
|
||||
'agent_type', 'is_default', 'is_active', 'organization_id',
|
||||
'transfer_to_human', 'ask_for_rating'
|
||||
]
|
||||
|
||||
for field in scalar_fields:
|
||||
assert getattr(result, field) == getattr(test_agent, field), f"Field {field} mismatch"
|
||||
|
||||
def test_jira_config_data_consistency(self, jira_repo, test_agent, test_agent_jira_config):
|
||||
"""Test that Jira config data is consistently mapped"""
|
||||
result = jira_repo.get_agent_with_jira_config(str(test_agent.id))
|
||||
|
||||
assert result is not None
|
||||
assert result.jira_enabled == test_agent_jira_config.enabled
|
||||
assert result.jira_project_key == test_agent_jira_config.project_key
|
||||
assert result.jira_issue_type_id == test_agent_jira_config.issue_type_id
|
||||
|
||||
def test_tools_list_integrity(self, jira_repo, test_agent, test_agent_jira_config):
|
||||
"""Test that tools list is properly handled"""
|
||||
result = jira_repo.get_agent_with_jira_config(str(test_agent.id))
|
||||
|
||||
assert result is not None
|
||||
assert result.tools == ["jira"] # Parsed from JSON string
|
||||
assert isinstance(result.tools, list)
|
||||
|
||||
def test_boolean_field_integrity(self, jira_repo, test_agent, test_agent_jira_config):
|
||||
"""Test that boolean fields maintain their types and values"""
|
||||
result = jira_repo.get_agent_with_jira_config(str(test_agent.id))
|
||||
|
||||
assert result is not None
|
||||
|
||||
# Test boolean fields
|
||||
boolean_fields = ['is_default', 'is_active', 'transfer_to_human', 'ask_for_rating', 'jira_enabled']
|
||||
|
||||
for field in boolean_fields:
|
||||
value = getattr(result, field)
|
||||
assert isinstance(value, bool), f"Field {field} should be boolean, got {type(value)}"
|
||||
|
||||
def test_uuid_field_integrity(self, jira_repo, test_agent, test_agent_jira_config):
|
||||
"""Test that UUID fields maintain their types"""
|
||||
result = jira_repo.get_agent_with_jira_config(str(test_agent.id))
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result.id, UUID)
|
||||
assert isinstance(result.organization_id, UUID)
|
||||
assert result.id == test_agent.id
|
||||
assert result.organization_id == test_agent.organization_id
|
||||
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.knowledge import Knowledge, SourceType
|
||||
from app.models.knowledge_queue import KnowledgeQueue, QueueStatus
|
||||
from app.repositories.knowledge import KnowledgeRepository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(db):
|
||||
return KnowledgeRepository(db)
|
||||
|
||||
|
||||
def _index(db, org_id, source: str) -> Knowledge:
|
||||
"""A finished crawl: the row the source ends up as."""
|
||||
row = Knowledge(
|
||||
source=source,
|
||||
source_type=SourceType.FILE,
|
||||
organization_id=org_id,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return row
|
||||
|
||||
|
||||
def _queue(db, org_id, source: str, status=QueueStatus.PENDING, age=None) -> KnowledgeQueue:
|
||||
"""A crawl that has been accepted but has produced nothing yet."""
|
||||
started = datetime.now(timezone.utc) - (age or timedelta(0))
|
||||
item = KnowledgeQueue(
|
||||
organization_id=org_id,
|
||||
source_type="website",
|
||||
source=source,
|
||||
status=status,
|
||||
created_at=started,
|
||||
updated_at=started,
|
||||
)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
return item
|
||||
|
||||
|
||||
class TestCountSourcesInUse:
|
||||
def test_counts_indexed_sources(self, repo, db, test_organization):
|
||||
assert repo.count_sources_in_use(test_organization.id) == 0
|
||||
_index(db, test_organization.id, "https://a.example")
|
||||
assert repo.count_sources_in_use(test_organization.id) == 1
|
||||
|
||||
@pytest.mark.parametrize("status", [QueueStatus.PENDING, QueueStatus.PROCESSING])
|
||||
def test_a_queued_crawl_holds_its_slot(self, repo, db, test_organization, status):
|
||||
"""The bug: three sites queued at once each saw a count of zero, so a
|
||||
one-source plan ended up with three."""
|
||||
_queue(db, test_organization.id, "https://a.example", status)
|
||||
_queue(db, test_organization.id, "https://b.example", status)
|
||||
assert repo.count_sources_in_use(test_organization.id) == 2
|
||||
|
||||
def test_a_finished_crawl_is_not_counted_twice(self, repo, db, test_organization):
|
||||
"""The queue row stays behind once the source is indexed."""
|
||||
_queue(db, test_organization.id, "https://a.example", QueueStatus.COMPLETED)
|
||||
_index(db, test_organization.id, "https://a.example")
|
||||
assert repo.count_sources_in_use(test_organization.id) == 1
|
||||
|
||||
def test_the_same_url_queued_twice_holds_two_slots(self, repo, db, test_organization):
|
||||
"""Each queue row becomes its own knowledge row - the worker does not
|
||||
merge repeats - so counting distinct sources let a customer who kept
|
||||
pressing Add walk past the limit."""
|
||||
_queue(db, test_organization.id, "https://a.example")
|
||||
_queue(db, test_organization.id, "https://a.example")
|
||||
assert repo.count_sources_in_use(test_organization.id) == 2
|
||||
|
||||
def test_a_crawl_abandoned_mid_flight_stops_holding_its_slot(self, repo, db, test_organization):
|
||||
"""Only PENDING rows are ever retried, so a row left PROCESSING by a
|
||||
restart would otherwise cost a slot for ever."""
|
||||
_queue(db, test_organization.id, "https://a.example", QueueStatus.PROCESSING,
|
||||
age=KnowledgeRepository.STALE_PROCESSING_AFTER + timedelta(minutes=1))
|
||||
assert repo.count_sources_in_use(test_organization.id) == 0
|
||||
|
||||
def test_a_long_running_crawl_keeps_its_slot(self, repo, db, test_organization):
|
||||
_queue(db, test_organization.id, "https://a.example", QueueStatus.PROCESSING,
|
||||
age=KnowledgeRepository.STALE_PROCESSING_AFTER - timedelta(minutes=5))
|
||||
assert repo.count_sources_in_use(test_organization.id) == 1
|
||||
|
||||
def test_failed_and_completed_crawls_hold_nothing(self, repo, db, test_organization):
|
||||
_queue(db, test_organization.id, "https://a.example", QueueStatus.FAILED)
|
||||
_queue(db, test_organization.id, "https://b.example", QueueStatus.COMPLETED)
|
||||
assert repo.count_sources_in_use(test_organization.id) == 0
|
||||
|
||||
def test_another_organization_is_not_counted(self, repo, db, test_organization):
|
||||
other_org = uuid4()
|
||||
_queue(db, other_org, "https://a.example")
|
||||
_index(db, other_org, "https://b.example")
|
||||
assert repo.count_sources_in_use(test_organization.id) == 0
|
||||
|
||||
def test_indexed_count_still_excludes_the_queue(self, repo, db, test_organization):
|
||||
"""count_by_organization backs the paginated list, so it must keep
|
||||
counting only what the list can actually show."""
|
||||
_queue(db, test_organization.id, "https://a.example")
|
||||
assert repo.count_by_organization(test_organization.id) == 0
|
||||
assert repo.count_sources_in_use(test_organization.id) == 1
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from uuid import uuid4
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.repositories.mcp_tool import MCPToolRepository
|
||||
from app.models.mcp_tool import MCPTransportType, MCPTool, MCPToolToAgent
|
||||
from tests.conftest import TestingSessionLocal, create_tables, Base
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def db() -> Session:
|
||||
create_tables()
|
||||
session = TestingSessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
Base.metadata.drop_all(bind=session.bind)
|
||||
|
||||
|
||||
def _create_tool(repo: MCPToolRepository, org_id):
|
||||
return repo.create_mcp_tool(
|
||||
name="Test MCP Tool",
|
||||
description="Test description",
|
||||
transport_type=MCPTransportType.STDIO,
|
||||
command="npx",
|
||||
args=["-y", "@modelcontextprotocol/server-filesystem"],
|
||||
env_vars={"FOO": "bar"},
|
||||
organization_id=org_id,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
|
||||
def test_create_and_get_mcp_tool(db: Session):
|
||||
repo = MCPToolRepository(db)
|
||||
org_id = uuid4()
|
||||
|
||||
created = _create_tool(repo, org_id)
|
||||
assert isinstance(created, MCPTool)
|
||||
assert created.id is not None
|
||||
assert created.name == "Test MCP Tool"
|
||||
|
||||
fetched = repo.get_mcp_tool(created.id)
|
||||
assert fetched is not None
|
||||
assert fetched.id == created.id
|
||||
|
||||
|
||||
def test_get_by_name_and_org(db: Session):
|
||||
repo = MCPToolRepository(db)
|
||||
org_id = uuid4()
|
||||
other_org = uuid4()
|
||||
|
||||
tool = _create_tool(repo, org_id)
|
||||
_create_tool(repo, other_org)
|
||||
|
||||
same = repo.get_by_name("Test MCP Tool", org_id)
|
||||
assert same is not None
|
||||
assert same.id == tool.id
|
||||
|
||||
none_other = repo.get_by_name("Does Not Exist", org_id)
|
||||
assert none_other is None
|
||||
|
||||
|
||||
def test_get_org_mcp_tools_enabled_flag(db: Session):
|
||||
repo = MCPToolRepository(db)
|
||||
org_id = uuid4()
|
||||
|
||||
t1 = _create_tool(repo, org_id)
|
||||
t2 = repo.create_mcp_tool(
|
||||
name="Disabled Tool",
|
||||
description="",
|
||||
transport_type=MCPTransportType.HTTP,
|
||||
url="https://example.com/mcp",
|
||||
headers={"Authorization": "Bearer token"},
|
||||
timeout=30,
|
||||
terminate_on_close=False,
|
||||
organization_id=org_id,
|
||||
enabled=False,
|
||||
)
|
||||
|
||||
enabled_only = repo.get_org_mcp_tools(org_id, enabled_only=True)
|
||||
assert t1 in enabled_only
|
||||
assert t2 not in enabled_only
|
||||
|
||||
all_tools = repo.get_org_mcp_tools(org_id, enabled_only=False)
|
||||
assert set(a.id for a in all_tools) == {t1.id, t2.id}
|
||||
|
||||
|
||||
def test_update_and_delete_mcp_tool(db: Session):
|
||||
repo = MCPToolRepository(db)
|
||||
org_id = uuid4()
|
||||
tool = _create_tool(repo, org_id)
|
||||
|
||||
updated = repo.update_mcp_tool(tool.id, description="Updated", enabled=False)
|
||||
assert updated.description == "Updated"
|
||||
assert updated.enabled is False
|
||||
|
||||
assert repo.delete_mcp_tool(tool.id) is True
|
||||
assert repo.get_mcp_tool(tool.id) is None
|
||||
|
||||
|
||||
def test_agent_associations(db: Session):
|
||||
repo = MCPToolRepository(db)
|
||||
org_id = uuid4()
|
||||
tool = _create_tool(repo, org_id)
|
||||
agent_id = uuid4()
|
||||
|
||||
assoc = repo.add_mcp_tool_to_agent(tool.id, agent_id)
|
||||
assert isinstance(assoc, MCPToolToAgent)
|
||||
assert assoc.mcp_tool_id == tool.id
|
||||
assert assoc.agent_id == agent_id
|
||||
|
||||
# Adding again returns existing association
|
||||
again = repo.add_mcp_tool_to_agent(tool.id, agent_id)
|
||||
assert again.id == assoc.id
|
||||
|
||||
tools_for_agent = repo.get_agent_mcp_tools(agent_id)
|
||||
assert any(t.id == tool.id for t in tools_for_agent)
|
||||
|
||||
agent_ids = repo.get_mcp_tool_agents(tool.id)
|
||||
assert agent_id in agent_ids
|
||||
|
||||
# Remove link
|
||||
assert repo.remove_mcp_tool_from_agent(tool.id, agent_id) is True
|
||||
assert repo.remove_mcp_tool_from_agent(tool.id, agent_id) is False
|
||||
|
||||
|
||||
def test_get_mcp_tool_with_agents(db: Session):
|
||||
repo = MCPToolRepository(db)
|
||||
org_id = uuid4()
|
||||
tool = _create_tool(repo, org_id)
|
||||
agent_id = uuid4()
|
||||
repo.add_mcp_tool_to_agent(tool.id, agent_id)
|
||||
|
||||
fetched = repo.get_mcp_tool_with_agents(tool.id)
|
||||
assert fetched is not None
|
||||
# Ensure relationship is accessible
|
||||
assert hasattr(fetched, "agent_links")
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,325 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, ANY
|
||||
from sqlalchemy.orm import Session
|
||||
from app.repositories.shopify_shop_repository import ShopifyShopRepository
|
||||
from app.models.shopify import ShopifyShop
|
||||
from app.models.schemas.shopify import ShopifyShopCreate, ShopifyShopUpdate
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
"""Create a mock database session"""
|
||||
db = MagicMock(spec=Session)
|
||||
return db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_shop_data():
|
||||
"""Sample shop data for testing"""
|
||||
return {
|
||||
"id": str(uuid.uuid4()),
|
||||
"shop_domain": "test-shop.myshopify.com",
|
||||
"access_token": "test_access_token",
|
||||
"scope": "read_products,write_products",
|
||||
"is_installed": True,
|
||||
"organization_id": str(uuid.uuid4()),
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"updated_at": datetime.now(timezone.utc)
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_shop(sample_shop_data):
|
||||
"""Create a mock ShopifyShop instance"""
|
||||
shop = MagicMock(spec=ShopifyShop)
|
||||
for key, value in sample_shop_data.items():
|
||||
setattr(shop, key, value)
|
||||
return shop
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repository(mock_db):
|
||||
"""Create a ShopifyShopRepository instance with mocked dependencies"""
|
||||
return ShopifyShopRepository(mock_db)
|
||||
|
||||
|
||||
def test_get_shop(repository, mock_db, mock_shop, sample_shop_data):
|
||||
"""Test getting a shop by ID"""
|
||||
# Arrange
|
||||
shop_id = sample_shop_data["id"]
|
||||
query_mock = MagicMock()
|
||||
filter_mock = MagicMock()
|
||||
first_mock = MagicMock(return_value=mock_shop)
|
||||
|
||||
mock_db.query.return_value = query_mock
|
||||
query_mock.filter.return_value = filter_mock
|
||||
filter_mock.first.return_value = mock_shop
|
||||
|
||||
# Act
|
||||
result = repository.get_shop(shop_id)
|
||||
|
||||
# Assert
|
||||
assert result == mock_shop
|
||||
mock_db.query.assert_called_once_with(ShopifyShop)
|
||||
query_mock.filter.assert_called_once()
|
||||
filter_mock.first.assert_called_once()
|
||||
assert str(result.organization_id) == sample_shop_data["organization_id"]
|
||||
|
||||
|
||||
def test_get_shop_by_domain(repository, mock_db, mock_shop, sample_shop_data):
|
||||
"""Test getting a shop by domain"""
|
||||
# Arrange
|
||||
shop_domain = sample_shop_data["shop_domain"]
|
||||
query_mock = MagicMock()
|
||||
filter_mock = MagicMock()
|
||||
first_mock = MagicMock(return_value=mock_shop)
|
||||
|
||||
mock_db.query.return_value = query_mock
|
||||
query_mock.filter.return_value = filter_mock
|
||||
filter_mock.first.return_value = mock_shop
|
||||
|
||||
# Act
|
||||
result = repository.get_shop_by_domain(shop_domain)
|
||||
|
||||
# Assert
|
||||
assert result == mock_shop
|
||||
mock_db.query.assert_called_once_with(ShopifyShop)
|
||||
query_mock.filter.assert_called_once()
|
||||
filter_mock.first.assert_called_once()
|
||||
assert str(result.organization_id) == sample_shop_data["organization_id"]
|
||||
|
||||
|
||||
def test_get_shop_not_found(repository, mock_db):
|
||||
"""Test getting a shop that doesn't exist"""
|
||||
# Arrange
|
||||
shop_id = str(uuid.uuid4())
|
||||
query_mock = MagicMock()
|
||||
filter_mock = MagicMock()
|
||||
first_mock = MagicMock(return_value=None)
|
||||
|
||||
mock_db.query.return_value = query_mock
|
||||
query_mock.filter.return_value = filter_mock
|
||||
filter_mock.first.return_value = None
|
||||
|
||||
# Act
|
||||
result = repository.get_shop(shop_id)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
mock_db.query.assert_called_once_with(ShopifyShop)
|
||||
query_mock.filter.assert_called_once()
|
||||
filter_mock.first.assert_called_once()
|
||||
|
||||
|
||||
def test_get_shops(repository, mock_db):
|
||||
"""Test getting all shops with pagination"""
|
||||
# Arrange
|
||||
shops = [MagicMock(spec=ShopifyShop) for _ in range(3)]
|
||||
for i, shop in enumerate(shops):
|
||||
shop.id = str(uuid.uuid4())
|
||||
shop.organization_id = str(uuid.uuid4())
|
||||
|
||||
query_mock = MagicMock()
|
||||
offset_mock = MagicMock()
|
||||
limit_mock = MagicMock()
|
||||
all_mock = MagicMock(return_value=shops)
|
||||
|
||||
mock_db.query.return_value = query_mock
|
||||
query_mock.offset.return_value = offset_mock
|
||||
offset_mock.limit.return_value = limit_mock
|
||||
limit_mock.all.return_value = shops
|
||||
|
||||
# Act
|
||||
result = repository.get_shops(skip=0, limit=10)
|
||||
|
||||
# Assert
|
||||
assert result == shops
|
||||
assert len(result) == 3
|
||||
mock_db.query.assert_called_once_with(ShopifyShop)
|
||||
query_mock.offset.assert_called_once_with(0)
|
||||
offset_mock.limit.assert_called_once_with(10)
|
||||
limit_mock.all.assert_called_once()
|
||||
|
||||
# Verify organization_id is properly serialized
|
||||
for shop in result:
|
||||
assert isinstance(shop.organization_id, str)
|
||||
|
||||
|
||||
def test_get_shops_by_organization(repository, mock_db):
|
||||
"""Test getting shops by organization ID"""
|
||||
# Arrange
|
||||
organization_id = str(uuid.uuid4())
|
||||
shops = [MagicMock(spec=ShopifyShop) for _ in range(2)]
|
||||
for shop in shops:
|
||||
shop.id = str(uuid.uuid4())
|
||||
shop.organization_id = organization_id
|
||||
|
||||
query_mock = MagicMock()
|
||||
filter_mock = MagicMock()
|
||||
offset_mock = MagicMock()
|
||||
limit_mock = MagicMock()
|
||||
all_mock = MagicMock(return_value=shops)
|
||||
|
||||
mock_db.query.return_value = query_mock
|
||||
query_mock.filter.return_value = filter_mock
|
||||
filter_mock.offset.return_value = offset_mock
|
||||
offset_mock.limit.return_value = limit_mock
|
||||
limit_mock.all.return_value = shops
|
||||
|
||||
# Act
|
||||
result = repository.get_shops_by_organization(organization_id, skip=0, limit=10)
|
||||
|
||||
# Assert
|
||||
assert result == shops
|
||||
assert len(result) == 2
|
||||
mock_db.query.assert_called_once_with(ShopifyShop)
|
||||
query_mock.filter.assert_called_once()
|
||||
filter_mock.offset.assert_called_once_with(0)
|
||||
offset_mock.limit.assert_called_once_with(10)
|
||||
limit_mock.all.assert_called_once()
|
||||
|
||||
|
||||
def test_create_shop(repository, mock_db, sample_shop_data):
|
||||
"""Test creating a new shop"""
|
||||
# Arrange
|
||||
shop_create_data = {
|
||||
"shop_domain": sample_shop_data["shop_domain"],
|
||||
"access_token": sample_shop_data["access_token"],
|
||||
"scope": sample_shop_data["scope"],
|
||||
"is_installed": sample_shop_data["is_installed"],
|
||||
"organization_id": sample_shop_data["organization_id"]
|
||||
}
|
||||
shop_create = ShopifyShopCreate(**shop_create_data)
|
||||
|
||||
# Mock the database operations
|
||||
mock_db.add = MagicMock()
|
||||
mock_db.commit = MagicMock()
|
||||
mock_db.refresh = MagicMock()
|
||||
|
||||
# Act
|
||||
with patch('uuid.uuid4', return_value=uuid.UUID(sample_shop_data["id"])):
|
||||
result = repository.create_shop(shop_create)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, ShopifyShop)
|
||||
assert result.shop_domain == shop_create.shop_domain
|
||||
assert result.access_token == shop_create.access_token
|
||||
assert result.is_installed == shop_create.is_installed
|
||||
assert str(result.organization_id) == shop_create.organization_id
|
||||
|
||||
mock_db.add.assert_called_once()
|
||||
mock_db.commit.assert_called_once()
|
||||
mock_db.refresh.assert_called_once()
|
||||
|
||||
|
||||
def test_update_shop(repository, mock_db, mock_shop, sample_shop_data):
|
||||
"""Test updating an existing shop"""
|
||||
# Arrange
|
||||
shop_id = sample_shop_data["id"]
|
||||
update_data = {
|
||||
"access_token": "new_access_token",
|
||||
"is_installed": False
|
||||
}
|
||||
shop_update = ShopifyShopUpdate(**update_data)
|
||||
|
||||
# Mock the get_shop method to return the mock_shop
|
||||
repository.get_shop = MagicMock(return_value=mock_shop)
|
||||
|
||||
# Mock the database operations
|
||||
mock_db.commit = MagicMock()
|
||||
mock_db.refresh = MagicMock()
|
||||
|
||||
# Act
|
||||
result = repository.update_shop(shop_id, shop_update)
|
||||
|
||||
# Assert
|
||||
assert result == mock_shop
|
||||
repository.get_shop.assert_called_once_with(shop_id)
|
||||
|
||||
# Verify the fields were updated
|
||||
assert mock_shop.access_token == update_data["access_token"]
|
||||
assert mock_shop.is_installed == update_data["is_installed"]
|
||||
|
||||
mock_db.commit.assert_called_once()
|
||||
mock_db.refresh.assert_called_once_with(mock_shop)
|
||||
|
||||
|
||||
def test_update_shop_not_found(repository, mock_db):
|
||||
"""Test updating a shop that doesn't exist"""
|
||||
# Arrange
|
||||
shop_id = str(uuid.uuid4())
|
||||
update_data = {
|
||||
"access_token": "new_access_token",
|
||||
"is_installed": False
|
||||
}
|
||||
shop_update = ShopifyShopUpdate(**update_data)
|
||||
|
||||
# Mock the get_shop method to return None (shop not found)
|
||||
repository.get_shop = MagicMock(return_value=None)
|
||||
|
||||
# Act
|
||||
result = repository.update_shop(shop_id, shop_update)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
repository.get_shop.assert_called_once_with(shop_id)
|
||||
mock_db.commit.assert_not_called()
|
||||
mock_db.refresh.assert_not_called()
|
||||
|
||||
|
||||
def test_delete_shop(repository, mock_db, mock_shop):
|
||||
"""Test deleting a shop"""
|
||||
# Arrange
|
||||
shop_id = mock_shop.id
|
||||
|
||||
# Mock the get_shop method to return the mock_shop
|
||||
repository.get_shop = MagicMock(return_value=mock_shop)
|
||||
|
||||
# Mock the database operations
|
||||
mock_db.delete = MagicMock()
|
||||
mock_db.commit = MagicMock()
|
||||
|
||||
# Act
|
||||
result = repository.delete_shop(shop_id)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
repository.get_shop.assert_called_once_with(shop_id)
|
||||
mock_db.delete.assert_called_once_with(mock_shop)
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_delete_shop_not_found(repository, mock_db):
|
||||
"""Test deleting a shop that doesn't exist"""
|
||||
# Arrange
|
||||
shop_id = str(uuid.uuid4())
|
||||
|
||||
# Mock the get_shop method to return None (shop not found)
|
||||
repository.get_shop = MagicMock(return_value=None)
|
||||
|
||||
# Act
|
||||
result = repository.delete_shop(shop_id)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
repository.get_shop.assert_called_once_with(shop_id)
|
||||
mock_db.delete.assert_not_called()
|
||||
mock_db.commit.assert_not_called()
|
||||
@@ -0,0 +1,742 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
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()
|
||||
Reference in new issue
Block a user