Initial commit: TradeMate 外贸小助手 MVP
项目结构: - backend/ Python FastAPI 后端 - uni-app/ uni-app跨端前端 - docs/ 设计文档 - docker-compose.yml Docker编排 - nginx/scripts/systemd 运维配置 已完成功能: - 用户认证 (JWT) - 智能翻译 + 回复建议 - 营销素材生成 - 客户管理 + 沉默检测 - 报价单管理 - 产品库管理 - 汇率换算 - 推送通知 (uni-push) - WhatsApp Webhook框架 - Celery定时任务
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
from typing import AsyncGenerator
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.main import app
|
||||
from app.database import Base, get_db
|
||||
from app.models.user import User
|
||||
from app.core.security import hash_password
|
||||
|
||||
|
||||
TEST_DATABASE_URL = "postgresql+asyncpg://admin:dWFNi67nHNbPbjmP@localhost:5432/foreign_trade_test"
|
||||
|
||||
test_engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||
TestAsyncSessionLocal = sessionmaker(
|
||||
test_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
async def db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with test_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async with TestAsyncSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
async with test_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
|
||||
async def override_get_db():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test"
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_user(db_session: AsyncSession) -> User:
|
||||
user = User(
|
||||
phone="13800138000",
|
||||
username="test_user",
|
||||
password_hash=hash_password("test123456"),
|
||||
tier="free",
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_headers(test_user: User) -> dict:
|
||||
from app.core.security import create_access_token
|
||||
token = create_access_token({"sub": str(test_user.id), "tier": test_user.tier})
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
@@ -0,0 +1,94 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
class TestAuthAPI:
|
||||
async def test_health_endpoint(self, client: AsyncClient):
|
||||
response = await client.get("/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["app"] == "TradeMate"
|
||||
|
||||
async def test_register_new_user(self, client: AsyncClient):
|
||||
response = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"phone": "13900139001",
|
||||
"password": "test123456",
|
||||
"username": "newuser",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["phone"] == "13900139001"
|
||||
assert data["username"] == "newuser"
|
||||
assert data["tier"] == "free"
|
||||
|
||||
async def test_register_duplicate_phone(self, client: AsyncClient, test_user):
|
||||
response = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"phone": "13800138000",
|
||||
"password": "test123456",
|
||||
"username": "duplicate",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "already registered" in response.json()["detail"]
|
||||
|
||||
async def test_login_success(self, client: AsyncClient, test_user):
|
||||
response = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={
|
||||
"username": "13800138000",
|
||||
"password": "test123456",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
async def test_login_wrong_password(self, client: AsyncClient, test_user):
|
||||
response = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={
|
||||
"username": "13800138000",
|
||||
"password": "wrongpassword",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_login_nonexistent_user(self, client: AsyncClient):
|
||||
response = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={
|
||||
"username": "13999999999",
|
||||
"password": "test123456",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_get_current_user(self, client: AsyncClient, auth_headers):
|
||||
response = await client.get("/api/v1/auth/me", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["phone"] == "13800138000"
|
||||
assert data["username"] == "test_user"
|
||||
|
||||
async def test_get_user_unauthorized(self, client: AsyncClient):
|
||||
response = await client.get("/api/v1/auth/me")
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_refresh_token(self, client: AsyncClient, test_user):
|
||||
from app.core.security import create_refresh_token
|
||||
refresh = create_refresh_token({"sub": str(test_user.id)})
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/auth/refresh",
|
||||
json={"refresh_token": refresh},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "access_token" in response.json()
|
||||
@@ -0,0 +1,42 @@
|
||||
import pytest
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class TestConfig:
|
||||
def test_app_name(self):
|
||||
assert settings.APP_NAME == "TradeMate"
|
||||
|
||||
def test_jwt_algorithm(self):
|
||||
assert settings.JWT_ALGORITHM == "HS256"
|
||||
|
||||
def test_token_expiration(self):
|
||||
assert settings.ACCESS_TOKEN_EXPIRE_MINUTES == 60
|
||||
assert settings.REFRESH_TOKEN_EXPIRE_DAYS == 30
|
||||
|
||||
def test_ai_routing_config(self):
|
||||
assert "translate" in settings.AI_ROUTING
|
||||
assert "reply" in settings.AI_ROUTING
|
||||
assert "marketing" in settings.AI_ROUTING
|
||||
assert settings.AI_ROUTING["translate"]["primary"] == "deepl"
|
||||
assert settings.AI_ROUTING["reply"]["primary"] == "openai"
|
||||
|
||||
def test_free_tier_limits(self):
|
||||
assert settings.FREE_DAILY_TRANSLATE_CHARS == 5000
|
||||
assert settings.FREE_DAILY_REPLIES == 20
|
||||
assert settings.FREE_DAILY_MARKETING == 5
|
||||
assert settings.FREE_MAX_CUSTOMERS == 5
|
||||
assert settings.FREE_MAX_PRODUCTS == 1
|
||||
assert settings.FREE_DAILY_QUOTATIONS == 3
|
||||
|
||||
def test_pro_tier_limits(self):
|
||||
assert settings.PRO_DAILY_TRANSLATE_CHARS == 50000
|
||||
assert settings.PRO_DAILY_REPLIES == 200
|
||||
assert settings.PRO_MAX_CUSTOMERS == 100
|
||||
assert settings.PRO_MAX_PRODUCTS == 20
|
||||
|
||||
def test_database_url_configured(self):
|
||||
assert settings.DATABASE_URL is not None
|
||||
assert "foreign_trade" in settings.DATABASE_URL
|
||||
|
||||
def test_redis_url_configured(self):
|
||||
assert settings.REDIS_URL is not None
|
||||
@@ -0,0 +1,147 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from app.models.customer import Customer
|
||||
import uuid
|
||||
|
||||
|
||||
class TestCustomerAPI:
|
||||
async def test_list_customers_empty(self, client: AsyncClient, auth_headers):
|
||||
response = await client.get("/api/v1/customers", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
async def test_create_customer(self, client: AsyncClient, auth_headers):
|
||||
response = await client.post(
|
||||
"/api/v1/customers",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"name": "John Smith",
|
||||
"company": "ABC Corp",
|
||||
"country": "USA",
|
||||
"phone": "+1234567890",
|
||||
"whatsapp_id": "john123",
|
||||
"email": "john@abc.com",
|
||||
"status": "lead",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "John Smith"
|
||||
assert data["company"] == "ABC Corp"
|
||||
assert data["country"] == "USA"
|
||||
|
||||
async def test_create_customer_minimal(self, client: AsyncClient, auth_headers):
|
||||
response = await client.post(
|
||||
"/api/v1/customers",
|
||||
headers=auth_headers,
|
||||
json={"name": "Minimal Customer"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "Minimal Customer"
|
||||
|
||||
async def test_list_customers_with_data(self, client: AsyncClient, auth_headers, db_session, test_user):
|
||||
customer = Customer(
|
||||
user_id=test_user.id,
|
||||
name="Test Customer",
|
||||
company="Test Co",
|
||||
country="China",
|
||||
status="lead",
|
||||
)
|
||||
db_session.add(customer)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.get("/api/v1/customers", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) == 1
|
||||
assert data["items"][0]["name"] == "Test Customer"
|
||||
|
||||
async def test_get_customer(self, client: AsyncClient, auth_headers, db_session, test_user):
|
||||
customer = Customer(
|
||||
user_id=test_user.id,
|
||||
name="Get Test",
|
||||
company="Get Co",
|
||||
status="negotiating",
|
||||
)
|
||||
db_session.add(customer)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.get(
|
||||
f"/api/v1/customers/{customer.id}",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "Get Test"
|
||||
|
||||
async def test_get_customer_not_found(self, client: AsyncClient, auth_headers):
|
||||
fake_id = str(uuid.uuid4())
|
||||
response = await client.get(
|
||||
f"/api/v1/customers/{fake_id}",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_update_customer(self, client: AsyncClient, auth_headers, db_session, test_user):
|
||||
customer = Customer(
|
||||
user_id=test_user.id,
|
||||
name="Original Name",
|
||||
status="lead",
|
||||
)
|
||||
db_session.add(customer)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/customers/{customer.id}",
|
||||
headers=auth_headers,
|
||||
json={"name": "Updated Name", "status": "negotiating"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "Updated Name"
|
||||
assert data["status"] == "negotiating"
|
||||
|
||||
async def test_delete_customer(self, client: AsyncClient, auth_headers, db_session, test_user):
|
||||
customer = Customer(
|
||||
user_id=test_user.id,
|
||||
name="To Delete",
|
||||
)
|
||||
db_session.add(customer)
|
||||
await db_session.commit()
|
||||
customer_id = customer.id
|
||||
|
||||
response = await client.delete(
|
||||
f"/api/v1/customers/{customer_id}",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
get_response = await client.get(
|
||||
f"/api/v1/customers/{customer_id}",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert get_response.status_code == 404
|
||||
|
||||
async def test_get_silent_customers(self, client: AsyncClient, auth_headers, db_session, test_user):
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
customer = Customer(
|
||||
user_id=test_user.id,
|
||||
name="Silent Customer",
|
||||
status="lead",
|
||||
last_contact_at=datetime.utcnow() - timedelta(days=5),
|
||||
)
|
||||
db_session.add(customer)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/customers/silent?days=3",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["count"] >= 1
|
||||
@@ -0,0 +1,45 @@
|
||||
import pytest
|
||||
from app.core.exceptions import (
|
||||
TradeMateException,
|
||||
NotFoundError,
|
||||
UnauthorizedError,
|
||||
ForbiddenError,
|
||||
QuotaExceededError,
|
||||
TierRestrictionError,
|
||||
)
|
||||
|
||||
|
||||
class TestExceptions:
|
||||
def test_trade_mate_exception(self):
|
||||
exc = TradeMateException(400, "Bad Request", "Details")
|
||||
assert exc.code == 400
|
||||
assert exc.message == "Bad Request"
|
||||
assert exc.detail == "Details"
|
||||
|
||||
def test_not_found_error(self):
|
||||
exc = NotFoundError("User")
|
||||
assert exc.code == 404
|
||||
assert "User" in exc.message
|
||||
assert "not found" in exc.message
|
||||
|
||||
def test_unauthorized_error(self):
|
||||
exc = UnauthorizedError()
|
||||
assert exc.code == 401
|
||||
assert exc.message == "Unauthorized"
|
||||
|
||||
def test_forbidden_error(self):
|
||||
exc = ForbiddenError()
|
||||
assert exc.code == 403
|
||||
assert exc.message == "Forbidden"
|
||||
|
||||
def test_quota_exceeded_error(self):
|
||||
exc = QuotaExceededError("translation")
|
||||
assert exc.code == 429
|
||||
assert "Quota exceeded" in exc.message
|
||||
assert "translation" in exc.detail
|
||||
|
||||
def test_tier_restriction_error(self):
|
||||
exc = TierRestrictionError("Advanced Feature", "Pro")
|
||||
assert exc.code == 402
|
||||
assert "Upgrade required" in exc.message
|
||||
assert "Pro" in exc.detail
|
||||
@@ -0,0 +1,47 @@
|
||||
import pytest
|
||||
from app.core.security import (
|
||||
hash_password,
|
||||
verify_password,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
)
|
||||
|
||||
|
||||
class TestSecurity:
|
||||
def test_hash_password(self):
|
||||
pwd = "test123456"
|
||||
hashed = hash_password(pwd)
|
||||
assert hashed != pwd
|
||||
assert verify_password(pwd, hashed)
|
||||
|
||||
def test_verify_password_wrong(self):
|
||||
pwd = "test123456"
|
||||
hashed = hash_password(pwd)
|
||||
assert not verify_password("wrongpassword", hashed)
|
||||
|
||||
def test_create_access_token(self):
|
||||
data = {"sub": "test-user-id", "tier": "free"}
|
||||
token = create_access_token(data)
|
||||
assert token is not None
|
||||
assert isinstance(token, str)
|
||||
|
||||
def test_decode_token_valid(self):
|
||||
data = {"sub": "test-user-id", "tier": "pro"}
|
||||
token = create_access_token(data)
|
||||
decoded = decode_token(token)
|
||||
assert decoded is not None
|
||||
assert decoded["sub"] == "test-user-id"
|
||||
assert decoded["tier"] == "pro"
|
||||
|
||||
def test_decode_token_invalid(self):
|
||||
decoded = decode_token("invalid-token")
|
||||
assert decoded is None
|
||||
|
||||
def test_create_refresh_token(self):
|
||||
data = {"sub": "test-user-id"}
|
||||
token = create_refresh_token(data)
|
||||
assert token is not None
|
||||
decoded = decode_token(token)
|
||||
assert decoded is not None
|
||||
assert decoded["type"] == "refresh"
|
||||
Reference in New Issue
Block a user