- Backend: FastAPI + SQLAlchemy + Celery (Python 3.11+) - Frontend: Vue 3 + TypeScript + Pinia + Tailwind - Admin Frontend: separate Vue 3 app for management - Docker Compose: 9 services orchestration - Specs: design prototypes, memory system PRD, product roadmap Cleanup performed: - Removed temporary debug scripts from backend root - Removed deprecated admin_app.py (embedded UI) - Removed duplicate docs from admin-frontend - Updated .gitignore for Vite cache and egg-info
258 lines
9.5 KiB
Python
258 lines
9.5 KiB
Python
"""故事 API 测试。"""
|
||
|
||
import time
|
||
from unittest.mock import AsyncMock, patch
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.api.stories import _request_log, RATE_LIMIT_REQUESTS
|
||
|
||
|
||
class TestStoryGenerate:
|
||
"""故事生成测试。"""
|
||
|
||
def test_generate_without_auth(self, client: TestClient):
|
||
"""未登录时生成故事。"""
|
||
response = client.post(
|
||
"/api/generate",
|
||
json={"type": "keywords", "data": "小兔子, 森林"},
|
||
)
|
||
assert response.status_code == 401
|
||
|
||
def test_generate_with_empty_data(self, auth_client: TestClient):
|
||
"""空数据生成故事。"""
|
||
response = auth_client.post(
|
||
"/api/generate",
|
||
json={"type": "keywords", "data": ""},
|
||
)
|
||
assert response.status_code == 422
|
||
|
||
def test_generate_with_invalid_type(self, auth_client: TestClient):
|
||
"""无效类型生成故事。"""
|
||
response = auth_client.post(
|
||
"/api/generate",
|
||
json={"type": "invalid", "data": "test"},
|
||
)
|
||
assert response.status_code == 422
|
||
|
||
def test_generate_story_success(self, auth_client: TestClient, mock_text_provider):
|
||
"""成功生成故事。"""
|
||
response = auth_client.post(
|
||
"/api/generate",
|
||
json={"type": "keywords", "data": "小兔子, 森林, 勇气"},
|
||
)
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert "id" in data
|
||
assert "title" in data
|
||
assert "story_text" in data
|
||
assert data["mode"] == "generated"
|
||
|
||
|
||
class TestStoryList:
|
||
"""故事列表测试。"""
|
||
|
||
def test_list_without_auth(self, client: TestClient):
|
||
"""未登录时获取列表。"""
|
||
response = client.get("/api/stories")
|
||
assert response.status_code == 401
|
||
|
||
def test_list_empty(self, auth_client: TestClient):
|
||
"""空列表。"""
|
||
response = auth_client.get("/api/stories")
|
||
assert response.status_code == 200
|
||
assert response.json() == []
|
||
|
||
def test_list_with_stories(self, auth_client: TestClient, test_story):
|
||
"""有故事时获取列表。"""
|
||
response = auth_client.get("/api/stories")
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert len(data) == 1
|
||
assert data[0]["id"] == test_story.id
|
||
assert data[0]["title"] == test_story.title
|
||
|
||
def test_list_pagination(self, auth_client: TestClient, test_story):
|
||
"""分页测试。"""
|
||
response = auth_client.get("/api/stories?limit=1&offset=0")
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert len(data) == 1
|
||
|
||
response = auth_client.get("/api/stories?limit=1&offset=1")
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert len(data) == 0
|
||
|
||
|
||
class TestStoryDetail:
|
||
"""故事详情测试。"""
|
||
|
||
def test_get_story_without_auth(self, client: TestClient, test_story):
|
||
"""未登录时获取详情。"""
|
||
response = client.get(f"/api/stories/{test_story.id}")
|
||
assert response.status_code == 401
|
||
|
||
def test_get_story_not_found(self, auth_client: TestClient):
|
||
"""故事不存在。"""
|
||
response = auth_client.get("/api/stories/99999")
|
||
assert response.status_code == 404
|
||
|
||
def test_get_story_success(self, auth_client: TestClient, test_story):
|
||
"""成功获取详情。"""
|
||
response = auth_client.get(f"/api/stories/{test_story.id}")
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert data["id"] == test_story.id
|
||
assert data["title"] == test_story.title
|
||
assert data["story_text"] == test_story.story_text
|
||
|
||
|
||
class TestStoryDelete:
|
||
"""故事删除测试。"""
|
||
|
||
def test_delete_without_auth(self, client: TestClient, test_story):
|
||
"""未登录时删除。"""
|
||
response = client.delete(f"/api/stories/{test_story.id}")
|
||
assert response.status_code == 401
|
||
|
||
def test_delete_not_found(self, auth_client: TestClient):
|
||
"""删除不存在的故事。"""
|
||
response = auth_client.delete("/api/stories/99999")
|
||
assert response.status_code == 404
|
||
|
||
def test_delete_success(self, auth_client: TestClient, test_story):
|
||
"""成功删除故事。"""
|
||
response = auth_client.delete(f"/api/stories/{test_story.id}")
|
||
assert response.status_code == 200
|
||
assert response.json()["message"] == "Deleted"
|
||
|
||
response = auth_client.get(f"/api/stories/{test_story.id}")
|
||
assert response.status_code == 404
|
||
|
||
|
||
class TestRateLimit:
|
||
"""Rate limit 测试。"""
|
||
|
||
def setup_method(self):
|
||
"""每个测试前清理 rate limit 缓存。"""
|
||
_request_log.clear()
|
||
|
||
def test_rate_limit_allows_normal_requests(self, auth_client: TestClient, test_story):
|
||
"""正常请求不触发限流。"""
|
||
for _ in range(RATE_LIMIT_REQUESTS - 1):
|
||
response = auth_client.get(f"/api/stories/{test_story.id}")
|
||
assert response.status_code == 200
|
||
|
||
def test_rate_limit_blocks_excess_requests(self, auth_client: TestClient, test_story):
|
||
"""超限请求被阻止。"""
|
||
for _ in range(RATE_LIMIT_REQUESTS):
|
||
auth_client.get(f"/api/stories/{test_story.id}")
|
||
|
||
response = auth_client.get(f"/api/stories/{test_story.id}")
|
||
assert response.status_code == 429
|
||
assert "Too many requests" in response.json()["detail"]
|
||
|
||
|
||
class TestImageGenerate:
|
||
"""封面图片生成测试。"""
|
||
|
||
def test_generate_image_without_auth(self, client: TestClient, test_story):
|
||
"""未登录时生成图片。"""
|
||
response = client.post(f"/api/image/generate/{test_story.id}")
|
||
assert response.status_code == 401
|
||
|
||
def test_generate_image_not_found(self, auth_client: TestClient):
|
||
"""故事不存在。"""
|
||
response = auth_client.post("/api/image/generate/99999")
|
||
assert response.status_code == 404
|
||
|
||
|
||
class TestAudio:
|
||
"""语音朗读测试。"""
|
||
|
||
def test_get_audio_without_auth(self, client: TestClient, test_story):
|
||
"""未登录时获取音频。"""
|
||
response = client.get(f"/api/audio/{test_story.id}")
|
||
assert response.status_code == 401
|
||
|
||
def test_get_audio_not_found(self, auth_client: TestClient):
|
||
"""故事不存在。"""
|
||
response = auth_client.get("/api/audio/99999")
|
||
assert response.status_code == 404
|
||
|
||
def test_get_audio_success(self, auth_client: TestClient, test_story, mock_tts_provider):
|
||
"""成功获取音频。"""
|
||
response = auth_client.get(f"/api/audio/{test_story.id}")
|
||
assert response.status_code == 200
|
||
assert response.headers["content-type"] == "audio/mpeg"
|
||
|
||
|
||
class TestGenerateFull:
|
||
"""完整故事生成测试(/api/generate/full)。"""
|
||
|
||
def test_generate_full_without_auth(self, client: TestClient):
|
||
"""未登录时生成完整故事。"""
|
||
response = client.post(
|
||
"/api/generate/full",
|
||
json={"type": "keywords", "data": "小兔子, 森林"},
|
||
)
|
||
assert response.status_code == 401
|
||
|
||
def test_generate_full_success(self, auth_client: TestClient, mock_text_provider, mock_image_provider):
|
||
"""成功生成完整故事(含图片)。"""
|
||
response = auth_client.post(
|
||
"/api/generate/full",
|
||
json={"type": "keywords", "data": "小兔子, 森林, 勇气"},
|
||
)
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert "id" in data
|
||
assert "title" in data
|
||
assert "story_text" in data
|
||
assert data["mode"] == "generated"
|
||
assert data["image_url"] == "https://example.com/image.png"
|
||
assert data["audio_ready"] is False # 音频按需生成
|
||
assert data["errors"] == {}
|
||
|
||
def test_generate_full_image_failure(self, auth_client: TestClient, mock_text_provider):
|
||
"""图片生成失败时返回部分成功。"""
|
||
with patch("app.api.stories.generate_image", new_callable=AsyncMock) as mock_img:
|
||
mock_img.side_effect = Exception("Image API error")
|
||
response = auth_client.post(
|
||
"/api/generate/full",
|
||
json={"type": "keywords", "data": "小兔子, 森林"},
|
||
)
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert data["image_url"] is None
|
||
assert "image" in data["errors"]
|
||
assert "Image API error" in data["errors"]["image"]
|
||
|
||
def test_generate_full_with_education_theme(self, auth_client: TestClient, mock_text_provider, mock_image_provider):
|
||
"""带教育主题生成故事。"""
|
||
response = auth_client.post(
|
||
"/api/generate/full",
|
||
json={
|
||
"type": "keywords",
|
||
"data": "小兔子, 森林",
|
||
"education_theme": "勇气与友谊",
|
||
},
|
||
)
|
||
assert response.status_code == 200
|
||
mock_text_provider.assert_called_once()
|
||
call_kwargs = mock_text_provider.call_args.kwargs
|
||
assert call_kwargs["education_theme"] == "勇气与友谊"
|
||
|
||
|
||
class TestImageGenerateSuccess:
|
||
"""封面图片生成成功测试。"""
|
||
|
||
def test_generate_image_success(self, auth_client: TestClient, test_story, mock_image_provider):
|
||
"""成功生成图片。"""
|
||
response = auth_client.post(f"/api/image/generate/{test_story.id}")
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert data["image_url"] == "https://example.com/image.png"
|