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

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

No files matched your search

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