feat: persist story generation states and cache audio
Some checks failed
Build and Push Docker Images / changes (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / build-admin-frontend (push) Has been cancelled
Some checks failed
Build and Push Docker Images / changes (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / build-admin-frontend (push) Has been cancelled
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""测试配置和 fixtures。"""
|
||||
"""Pytest fixtures for backend tests."""
|
||||
|
||||
import os
|
||||
from collections.abc import AsyncGenerator
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///:memory:")
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import create_access_token
|
||||
from app.db.database import get_db
|
||||
from app.db.models import Base, Story, User
|
||||
@@ -19,7 +20,8 @@ from app.main import app
|
||||
|
||||
@pytest.fixture
|
||||
async def async_engine():
|
||||
"""创建内存数据库引擎。"""
|
||||
"""Create an in-memory database engine."""
|
||||
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
@@ -29,7 +31,8 @@ async def async_engine():
|
||||
|
||||
@pytest.fixture
|
||||
async def db_session(async_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""创建数据库会话。"""
|
||||
"""Create a database session."""
|
||||
|
||||
session_factory = async_sessionmaker(
|
||||
async_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
@@ -39,7 +42,8 @@ async def db_session(async_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||
|
||||
@pytest.fixture
|
||||
async def test_user(db_session: AsyncSession) -> User:
|
||||
"""创建测试用户。"""
|
||||
"""Create a test user."""
|
||||
|
||||
user = User(
|
||||
id="github:12345",
|
||||
name="Test User",
|
||||
@@ -54,13 +58,74 @@ async def test_user(db_session: AsyncSession) -> User:
|
||||
|
||||
@pytest.fixture
|
||||
async def test_story(db_session: AsyncSession, test_user: User) -> Story:
|
||||
"""创建测试故事。"""
|
||||
"""Create a plain generated story."""
|
||||
|
||||
story = Story(
|
||||
user_id=test_user.id,
|
||||
title="测试故事",
|
||||
story_text="从前有一只小兔子...",
|
||||
story_text="从前有一只小兔子。",
|
||||
cover_prompt="A cute rabbit in a forest",
|
||||
mode="generated",
|
||||
generation_status="narrative_ready",
|
||||
image_status="not_requested",
|
||||
audio_status="not_requested",
|
||||
)
|
||||
db_session.add(story)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(story)
|
||||
return story
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def storybook_story(db_session: AsyncSession, test_user: User) -> Story:
|
||||
"""Create a storybook-mode story."""
|
||||
|
||||
story = Story(
|
||||
user_id=test_user.id,
|
||||
title="森林绘本冒险",
|
||||
story_text=None,
|
||||
pages=[
|
||||
{
|
||||
"page_number": 1,
|
||||
"text": "小兔子走进了会发光的森林。",
|
||||
"image_prompt": "A glowing forest with a curious rabbit",
|
||||
"image_url": "https://example.com/page-1.png",
|
||||
},
|
||||
{
|
||||
"page_number": 2,
|
||||
"text": "它遇见了一位会唱歌的萤火虫朋友。",
|
||||
"image_prompt": "A rabbit meeting a singing firefly",
|
||||
"image_url": None,
|
||||
},
|
||||
],
|
||||
cover_prompt="A magical forest storybook cover",
|
||||
image_url="https://example.com/storybook-cover.png",
|
||||
mode="storybook",
|
||||
generation_status="degraded_completed",
|
||||
image_status="failed",
|
||||
audio_status="not_requested",
|
||||
last_error="第 2 页插图生成失败",
|
||||
)
|
||||
db_session.add(story)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(story)
|
||||
return story
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def degraded_story_with_text(db_session: AsyncSession, test_user: User) -> Story:
|
||||
"""Create a readable story whose image generation already failed."""
|
||||
|
||||
story = Story(
|
||||
user_id=test_user.id,
|
||||
title="部分完成的测试故事",
|
||||
story_text="从前有一只小兔子继续冒险。",
|
||||
cover_prompt="A rabbit under the moon",
|
||||
mode="generated",
|
||||
generation_status="degraded_completed",
|
||||
image_status="failed",
|
||||
audio_status="not_requested",
|
||||
last_error="封面生成失败",
|
||||
)
|
||||
db_session.add(story)
|
||||
await db_session.commit()
|
||||
@@ -70,13 +135,14 @@ async def test_story(db_session: AsyncSession, test_user: User) -> Story:
|
||||
|
||||
@pytest.fixture
|
||||
def auth_token(test_user: User) -> str:
|
||||
"""生成测试用户的 JWT token。"""
|
||||
"""Create a JWT token for the test user."""
|
||||
|
||||
return create_access_token({"sub": test_user.id})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(db_session: AsyncSession) -> TestClient:
|
||||
"""创建测试客户端。"""
|
||||
"""Create a test client."""
|
||||
|
||||
async def override_get_db():
|
||||
yield db_session
|
||||
@@ -89,35 +155,45 @@ def client(db_session: AsyncSession) -> TestClient:
|
||||
|
||||
@pytest.fixture
|
||||
def auth_client(client: TestClient, auth_token: str) -> TestClient:
|
||||
"""带认证的测试客户端。"""
|
||||
"""Create an authenticated test client."""
|
||||
|
||||
client.cookies.set("access_token", auth_token)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def bypass_rate_limit():
|
||||
"""默认绕过限流,让非限流测试正常运行。"""
|
||||
"""Bypass rate limiting in most tests."""
|
||||
|
||||
with patch("app.core.rate_limiter.get_redis", new_callable=AsyncMock) as mock_redis:
|
||||
# 创建一个模拟的 Redis 客户端,所有操作返回安全默认值
|
||||
redis_instance = AsyncMock()
|
||||
redis_instance.incr.return_value = 1 # 始终返回 1 (不触发限流)
|
||||
redis_instance.incr.return_value = 1
|
||||
redis_instance.expire.return_value = True
|
||||
redis_instance.get.return_value = None # 无锁定记录
|
||||
redis_instance.get.return_value = None
|
||||
redis_instance.ttl.return_value = 0
|
||||
redis_instance.delete.return_value = 1
|
||||
mock_redis.return_value = redis_instance
|
||||
yield redis_instance
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_story_audio_cache(tmp_path, monkeypatch):
|
||||
"""Use an isolated directory for cached story audio files."""
|
||||
|
||||
monkeypatch.setattr(settings, "story_audio_cache_dir", str(tmp_path / "audio"))
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_text_provider():
|
||||
"""Mock 文本生成适配器 API 调用。"""
|
||||
"""Mock text generation."""
|
||||
|
||||
from app.services.adapters.text.models import StoryOutput
|
||||
|
||||
mock_result = StoryOutput(
|
||||
mode="generated",
|
||||
title="小兔子的冒险",
|
||||
story_text="从前有一只小兔子...",
|
||||
story_text="从前有一只小兔子。",
|
||||
cover_prompt_suggestion="A cute rabbit",
|
||||
)
|
||||
|
||||
@@ -128,7 +204,8 @@ def mock_text_provider():
|
||||
|
||||
@pytest.fixture
|
||||
def mock_image_provider():
|
||||
"""Mock 图像生成。"""
|
||||
"""Mock image generation."""
|
||||
|
||||
with patch("app.services.story_service.generate_image", new_callable=AsyncMock) as mock:
|
||||
mock.return_value = "https://example.com/image.png"
|
||||
yield mock
|
||||
@@ -136,7 +213,8 @@ def mock_image_provider():
|
||||
|
||||
@pytest.fixture
|
||||
def mock_tts_provider():
|
||||
"""Mock TTS。"""
|
||||
"""Mock text-to-speech generation."""
|
||||
|
||||
with patch("app.services.provider_router.text_to_speech", new_callable=AsyncMock) as mock:
|
||||
mock.return_value = b"fake-audio-bytes"
|
||||
yield mock
|
||||
@@ -144,7 +222,8 @@ def mock_tts_provider():
|
||||
|
||||
@pytest.fixture
|
||||
def mock_all_providers(mock_text_provider, mock_image_provider, mock_tts_provider):
|
||||
"""Mock 所有 AI 供应商。"""
|
||||
"""Group all mocked providers."""
|
||||
|
||||
return {
|
||||
"text_primary": mock_text_provider,
|
||||
"image_primary": mock_image_provider,
|
||||
|
||||
Reference in New Issue
Block a user