Files
replypier/backend/tests/conftest.py
T
WangShen007 20fef2b9f3 chore: settle product naming across app, widget and deployments
One word everywhere: ReplyPier in UI copy, package and image names, the
replypier.site domains (app/api/docs/help), REPLYPIER_* settings and the
--rp-* design tokens.

- public widget API is window.ReplyPier with replypierId / replypierBaseUrl /
  replypierConfig / replypierToken, loader at webclient/replypier.min.js;
  both bundles rebuilt from source
- NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and the demo runtime
  config rewritten; the demo config ships no third-party keys
- migration adds the ReplyPier managed model value to the AI model enum
- local compose project, database, role, volume and Docker network carry the
  same name
2026-10-26 00:57:22 +08:00

383 lines
12 KiB
Python

"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import os
# Set before any app import: app.core.encryption reads these when it first loads a
# key, and a fixed key keeps encrypted fixtures reproducible across runs. This key
# is for tests only — deployments generate their own (see backend/.env.example).
os.environ.setdefault("ENVIRONMENT", "test")
os.environ.setdefault(
"ENCRYPTION_KEY", "QmFTbXc5RWQ4czRfQWpqSnhqZjhraGtYSGFYaXZ4SkRNS2kxZFB1Y2NrMD0=")
# NO_ENTERPRISE=1 — run the suite the way CI does:
#
# NO_ENTERPRISE=1 python -m pytest tests/
#
# The blocker lives in app/core/edition.py and is installed by app/__init__.py,
# so it is in place before the `from app...` imports below.
import pytest
import uuid
import asyncio
import sys
from sqlalchemy import create_engine, event, DDL, String
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from sqlalchemy.dialects import postgresql
from sqlalchemy.ext.compiler import compiles
from app.database import Base
from typing import Generator
from sqlalchemy.schema import CreateTable, Table
from app.models.organization import Organization
from uuid import UUID, uuid4
# Register a custom type compiler for PostgreSQL UUID to work with SQLite
# This teaches SQLite how to compile the PostgreSQL UUID type
@compiles(postgresql.UUID, 'sqlite')
def compile_uuid_sqlite(type_, compiler, **kw):
"""Compile PostgreSQL UUID type as CHAR(36) for SQLite"""
return "CHAR(36)"
# Teach SQLite how to compile the pgvector column (tickets.embedding).
# Similarity queries are Postgres-only and guarded in the repository; tests
# only need the table to create.
from pgvector.sqlalchemy import Vector
@compiles(Vector, 'sqlite')
def compile_vector_sqlite(type_, compiler, **kw):
return "TEXT"
from app.models.widget import Widget
from app.models.agent import Agent, AgentType
from app.models.ai_config import AIConfig, AIModelType
from app.models.customer import Customer
from app.models.user import User
from app.repositories.agent import AgentRepository
from app.repositories.widget import WidgetRepository
from app.models.schemas.widget import WidgetCreate
from app.core.security import encrypt_api_key, get_password_hash, create_access_token, create_refresh_token, create_conversation_token
from unittest.mock import MagicMock
from fastapi.testclient import TestClient
from app.main import app
from app.channels import app_hooks
from app.models.shopify.shopify_shop import ShopifyShop
from app.models.shopify.agent_shopify_config import AgentShopifyConfig
import jwt
from datetime import datetime, timedelta
from app.core.config import settings
from app.models.role import Role
from app.models.permission import Permission
# Test database URL
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
# Configure asyncio event loop policy to prevent "Event loop is closed" errors
@pytest.fixture(scope="function")
def event_loop():
"""Create a fresh event loop for each test function.
This prevents test pollution where one test's asyncio operations
affect subsequent tests.
"""
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
# Create a new event loop for this test
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
yield loop
# Clean up: cancel all pending tasks and close the loop
try:
pending = asyncio.all_tasks(loop)
for task in pending:
task.cancel()
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
except Exception:
pass # Ignore cleanup errors
finally:
try:
loop.close()
except Exception:
pass
# Reset the event loop to None so the next test gets a fresh one
asyncio.set_event_loop(None)
@pytest.fixture(autouse=True)
def setup_event_loop_for_sync_tests():
"""Ensure an event loop exists for synchronous tests that use asyncio internally.
This fixture runs automatically for every test and ensures that
asyncio.get_event_loop() doesn't raise RuntimeError.
"""
try:
loop = asyncio.get_event_loop()
if loop.is_closed():
raise RuntimeError("Event loop is closed")
except RuntimeError:
# No event loop exists, create one
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
yield
# Don't close the loop here - let the event_loop fixture handle cleanup
# for async tests, and for sync tests we just leave it for the next test
# Create test engine
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
# Create TestingSessionLocal class
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@event.listens_for(engine, "connect")
def do_connect(dbapi_connection, connection_record):
# Disable foreign key constraint enforcement
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=OFF")
cursor.close()
@event.listens_for(Table, 'before_create')
def _before_create_table(target, connection, **kw):
# For SQLite, we need to remove schema prefixes
if connection.engine.dialect.name == 'sqlite':
target.schema = None
def create_tables():
"""Create all tables in the test database"""
Base.metadata.create_all(bind=engine)
@pytest.fixture(scope="function")
def db() -> Generator:
"""Create a fresh database for each test."""
# Create all tables
create_tables()
# Create a new session for testing
db = TestingSessionLocal()
real_close = db.close
# Handlers under test now close their own db session (see the widget_chat.py DB
# pool leak fix) via `db = next(get_db())` monkeypatched to this shared fixture
# session. A real close() here would expunge/detach fixture objects mid-test, so
# it's neutered until the fixture's own teardown below.
db.close = lambda: None
try:
yield db
finally:
real_close()
# Drop all tables after test
Base.metadata.drop_all(bind=engine)
@pytest.fixture
def test_organization(db) -> Organization:
"""Create a test organization"""
org = Organization(
name="Test Organization",
domain="test.com",
timezone="UTC"
)
db.add(org)
db.commit()
db.refresh(org)
return org
@pytest.fixture
def test_organization_id(test_organization) -> UUID:
"""Return the ID of the test organization"""
return test_organization.id
@pytest.fixture
def test_ai_config(db, test_organization) -> AIConfig:
"""Create a test AI config"""
ai_config = AIConfig(
organization_id=test_organization.id,
model_type=AIModelType.OPENAI,
model_name="gpt-4",
encrypted_api_key=encrypt_api_key("test_key"),
is_active=True
)
db.add(ai_config)
db.commit()
db.refresh(ai_config)
return ai_config
@pytest.fixture
def test_agent(db, test_organization) -> Agent:
"""Create a test agent"""
agent_repo = AgentRepository(db)
agent = agent_repo.create_agent(
name="Test Agent",
agent_type=AgentType.CUSTOMER_SUPPORT,
instructions=["Test instructions"],
org_id=test_organization.id
)
return agent
@pytest.fixture
def test_widget(db, test_agent) -> Widget:
"""Create a test widget"""
widget_repo = WidgetRepository(db)
widget_create = WidgetCreate(
name="Test Widget",
agent_id=test_agent.id
)
widget = widget_repo.create_widget(
widget=widget_create,
organization_id=test_agent.organization_id
)
return widget
@pytest.fixture
def test_customer(db, test_organization) -> Customer:
"""Create a test customer"""
customer = Customer(
id=uuid4(),
organization_id=test_organization.id,
email="test.customer@example.com",
full_name="Test Customer"
)
db.add(customer)
db.commit()
db.refresh(customer)
return customer
@pytest.fixture
def test_permissions(db):
"""Create test permissions"""
permissions = []
for perm_name in ["manage_organization", "manage_users", "manage_agents"]:
perm = Permission(name=perm_name)
db.add(perm)
permissions.append(perm)
db.commit()
for perm in permissions:
db.refresh(perm)
return permissions
@pytest.fixture
def test_role(db, test_permissions, test_organization):
"""Create a test role with permissions"""
role = Role(
name="Test Admin",
organization_id=test_organization.id
)
role.permissions = test_permissions
db.add(role)
db.commit()
db.refresh(role)
return role
@pytest.fixture
def test_user(db, test_organization, test_role) -> User:
"""Create a test user"""
user = User(
id=uuid4(),
organization_id=test_organization.id,
email="test.user@example.com",
full_name="Test User",
hashed_password=get_password_hash("test_password"),
is_active=True,
role_id=test_role.id
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def test_access_token(test_user) -> str:
"""Create a test access token"""
token_data = {
"sub": str(test_user.id),
"org": str(test_user.organization_id)
}
return create_access_token(token_data)
@pytest.fixture
def test_refresh_token(test_user) -> str:
"""Create a test refresh token"""
token_data = {
"sub": str(test_user.id),
"org": str(test_user.organization_id)
}
return create_refresh_token(token_data)
@pytest.fixture
def test_conversation_token(test_widget, test_customer) -> str:
"""Create a test conversation token"""
return create_conversation_token(
widget_id=str(test_widget.id),
customer_id=str(test_customer.id)
)
@pytest.fixture
def mock_socketio():
"""Create a mock SocketIO instance"""
mock_sio = MagicMock()
mock_sio.emit = MagicMock()
mock_sio.save_session = MagicMock()
mock_sio.get_session = MagicMock()
return mock_sio
@pytest.fixture
def test_shopify_shop(db, test_organization):
"""Create a test Shopify shop."""
shop = ShopifyShop(
id=str(uuid.uuid4()),
shop_domain="test-store.myshopify.com",
access_token="test_access_token",
scope="read_products,write_products",
is_installed=True,
organization_id=test_organization.id
)
db.add(shop)
db.commit()
db.refresh(shop)
yield shop
db.delete(shop)
db.commit()
@pytest.fixture
def test_agent_shopify_config(db, test_agent, test_shopify_shop):
"""Create a test agent Shopify config."""
config = AgentShopifyConfig(
id=str(uuid.uuid4()),
agent_id=str(test_agent.id),
shop_id=test_shopify_shop.id,
enabled=True
)
db.add(config)
db.commit()
db.refresh(config)
yield config
db.delete(config)
db.commit()
@pytest.fixture(autouse=True)
def _reset_channel_hooks():
"""No test leaks an extension hook it registered into the next one."""
yield
app_hooks.reset()