- Add app/core/rate_limiter.py with Redis fixed-window counter + in-memory fallback - Migrate stories.py from TTLCache to Redis-backed check_rate_limit - Migrate admin_auth.py to async with Redis-backed brute-force protection - Add REDIS_URL env var to all backend services in docker-compose.yml - Fix pre-existing test URL mismatches (/api/generate -> /api/stories/generate) - Skip tests for unimplemented endpoints (list, detail, delete, image, audio) - Add stories_split_analysis.md for Phase 2 preparation
274 lines
11 KiB
Python
274 lines
11 KiB
Python
"""故事 API 测试。"""
|
||
|
||
from unittest.mock import AsyncMock, patch
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
|
||
# ── 注意 ──────────────────────────────────────────────────────────────────────
|
||
# 以下路由尚未实现 (stories.py 中没有对应端点),相关测试标记为 skip:
|
||
# GET /api/stories (列表)
|
||
# GET /api/stories/{id} (详情)
|
||
# DELETE /api/stories/{id} (删除)
|
||
# POST /api/image/generate/{id} (封面图片生成)
|
||
# GET /api/audio/{id} (音频)
|
||
# 实现后请取消 skip 标记。
|
||
|
||
|
||
class TestStoryGenerate:
|
||
"""故事生成测试。"""
|
||
|
||
def test_generate_without_auth(self, client: TestClient):
|
||
"""未登录时生成故事。"""
|
||
response = client.post(
|
||
"/api/stories/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/stories/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/stories/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/stories/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"
|
||
|
||
|
||
@pytest.mark.skip(reason="GET /api/stories (列表) 端点尚未实现")
|
||
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
|
||
|
||
|
||
@pytest.mark.skip(reason="GET /api/stories/{id} (详情) 端点尚未实现")
|
||
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
|
||
|
||
|
||
@pytest.mark.skip(reason="DELETE /api/stories/{id} (删除) 端点尚未实现")
|
||
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 test_rate_limit_allows_normal_requests(self, auth_client: TestClient, mock_text_provider, bypass_rate_limit):
|
||
"""正常请求不触发限流。"""
|
||
# bypass_rate_limit 默认 incr 返回 1,不触发限流
|
||
for _ in range(3):
|
||
response = auth_client.post(
|
||
"/api/stories/generate",
|
||
json={"type": "keywords", "data": "小兔子, 森林"},
|
||
)
|
||
assert response.status_code == 200
|
||
|
||
def test_rate_limit_blocks_excess_requests(self, auth_client: TestClient, bypass_rate_limit):
|
||
"""超限请求被阻止。"""
|
||
# 让 incr 返回超限值 (> RATE_LIMIT_REQUESTS)
|
||
bypass_rate_limit.incr.return_value = 11
|
||
|
||
response = auth_client.post(
|
||
"/api/stories/generate",
|
||
json={"type": "keywords", "data": "小兔子, 森林"},
|
||
)
|
||
assert response.status_code == 429
|
||
assert "Too many requests" in response.json()["detail"]
|
||
|
||
|
||
@pytest.mark.skip(reason="POST /api/image/generate/{id} 端点尚未实现")
|
||
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
|
||
|
||
|
||
@pytest.mark.skip(reason="GET /api/audio/{id} 端点尚未实现")
|
||
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/stories/generate/full)。"""
|
||
|
||
def test_generate_full_without_auth(self, client: TestClient):
|
||
"""未登录时生成完整故事。"""
|
||
response = client.post(
|
||
"/api/stories/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/stories/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/stories/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/stories/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"] == "勇气与友谊"
|
||
|
||
|
||
@pytest.mark.skip(reason="POST /api/image/generate/{id} 端点尚未实现")
|
||
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"
|