"""Tests for story-related API endpoints.""" from pathlib import Path from unittest.mock import AsyncMock, patch from fastapi.testclient import TestClient from app.core.config import settings from app.services.adapters.storybook.primary import Storybook, StorybookPage def build_storybook_output() -> Storybook: """Create a reusable mocked storybook payload.""" return Storybook( title="森林里的发光冒险", main_character="小兔子露露", art_style="温暖水彩", cover_prompt="A glowing forest storybook cover", pages=[ StorybookPage( page_number=1, text="露露第一次走进会发光的森林。", image_prompt="Lulu entering a glowing forest", ), StorybookPage( page_number=2, text="她遇到了一只会唱歌的萤火虫。", image_prompt="Lulu meeting a singing firefly", ), ], ) class TestStoryGenerate: """Tests for basic story generation.""" 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" assert data["generation_status"] == "narrative_ready" assert data["image_status"] == "not_requested" assert data["audio_status"] == "not_requested" assert data["last_error"] is None class TestStoryList: """Tests for story listing.""" 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 assert data[0]["generation_status"] == "narrative_ready" assert data[0]["image_status"] == "not_requested" assert data[0]["audio_status"] == "not_requested" 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 assert len(response.json()) == 1 response = auth_client.get("/api/stories?limit=1&offset=1") assert response.status_code == 200 assert len(response.json()) == 0 class TestStoryDetail: """Tests for story detail retrieval.""" 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 assert data["generation_status"] == "narrative_ready" assert data["image_status"] == "not_requested" assert data["audio_status"] == "not_requested" assert data["last_error"] is None def test_get_storybook_success(self, auth_client: TestClient, storybook_story): response = auth_client.get(f"/api/stories/{storybook_story.id}") assert response.status_code == 200 data = response.json() assert data["id"] == storybook_story.id assert data["mode"] == "storybook" assert data["story_text"] is None assert len(data["pages"]) == 2 assert data["pages"][0]["page_number"] == 1 assert data["image_url"] == "https://example.com/storybook-cover.png" assert data["generation_status"] == "degraded_completed" assert data["image_status"] == "failed" assert data["audio_status"] == "not_requested" assert "第 2 页" in data["last_error"] class TestStoryDelete: """Tests for story deletion.""" 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: """Tests for story generation rate limiting.""" def test_rate_limit_allows_normal_requests( self, auth_client: TestClient, mock_text_provider, bypass_rate_limit, ): 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, ): 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"] class TestImageGenerate: """Tests for cover generation endpoint.""" 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: """Tests for story audio endpoint.""" 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" assert response.content == b"fake-audio-bytes" cached_audio_path = Path(settings.story_audio_cache_dir) / f"story-{test_story.id}.mp3" assert cached_audio_path.is_file() second_response = auth_client.get(f"/api/audio/{test_story.id}") assert second_response.status_code == 200 assert second_response.content == b"fake-audio-bytes" mock_tts_provider.assert_awaited_once() detail_response = auth_client.get(f"/api/stories/{test_story.id}") detail = detail_response.json() assert detail["audio_status"] == "ready" assert detail["generation_status"] == "completed" assert detail["last_error"] is None def test_get_audio_regenerates_when_cache_file_is_missing( self, auth_client: TestClient, test_story, mock_tts_provider, ): first_response = auth_client.get(f"/api/audio/{test_story.id}") assert first_response.status_code == 200 cached_audio_path = Path(settings.story_audio_cache_dir) / f"story-{test_story.id}.mp3" cached_audio_path.unlink() mock_tts_provider.reset_mock() second_response = auth_client.get(f"/api/audio/{test_story.id}") assert second_response.status_code == 200 assert second_response.content == b"fake-audio-bytes" assert cached_audio_path.is_file() mock_tts_provider.assert_awaited_once() def test_get_audio_failure_updates_status(self, auth_client: TestClient, test_story): with patch("app.services.provider_router.text_to_speech", new_callable=AsyncMock) as mock_tts: mock_tts.side_effect = Exception("TTS provider timeout") response = auth_client.get(f"/api/audio/{test_story.id}") assert response.status_code == 500 detail_response = auth_client.get(f"/api/stories/{test_story.id}") detail = detail_response.json() assert detail["audio_status"] == "failed" assert detail["generation_status"] == "degraded_completed" assert "TTS provider timeout" in detail["last_error"] def test_get_audio_success_preserves_existing_image_error( self, auth_client: TestClient, degraded_story_with_text, mock_tts_provider, ): response = auth_client.get(f"/api/audio/{degraded_story_with_text.id}") assert response.status_code == 200 assert response.content == b"fake-audio-bytes" mock_tts_provider.assert_awaited_once() detail_response = auth_client.get(f"/api/stories/{degraded_story_with_text.id}") detail = detail_response.json() assert detail["audio_status"] == "ready" assert detail["generation_status"] == "degraded_completed" assert detail["last_error"] == "封面生成失败" class TestGenerateFull: """Tests for complete story generation.""" 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"] == {} assert data["generation_status"] == "completed" assert data["image_status"] == "ready" assert data["audio_status"] == "not_requested" assert data["last_error"] is None def test_generate_full_image_failure(self, auth_client: TestClient, mock_text_provider): with patch("app.services.story_service.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"] assert data["generation_status"] == "degraded_completed" assert data["image_status"] == "failed" assert data["audio_status"] == "not_requested" assert "Image API error" in data["last_error"] 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"] == "勇气与友谊" class TestImageGenerateSuccess: """Tests for successful cover generation.""" 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" assert data["generation_status"] == "completed" assert data["image_status"] == "ready" assert data["audio_status"] == "not_requested" assert data["last_error"] is None class TestStorybookGenerate: """Tests for storybook generation status handling.""" def test_generate_storybook_success(self, auth_client: TestClient): with patch("app.services.story_service.generate_storybook", new_callable=AsyncMock) as mock_storybook: with patch("app.services.story_service.generate_image", new_callable=AsyncMock) as mock_image: mock_storybook.return_value = build_storybook_output() mock_image.side_effect = [ "https://example.com/storybook-cover.png", "https://example.com/storybook-page-1.png", "https://example.com/storybook-page-2.png", ] response = auth_client.post( "/api/storybook/generate", json={ "keywords": "森林, 发光, 友情", "page_count": 6, "generate_images": True, }, ) assert response.status_code == 200 data = response.json() assert data["id"] is not None assert data["generation_status"] == "completed" assert data["image_status"] == "ready" assert data["audio_status"] == "not_requested" assert data["last_error"] is None assert len(data["pages"]) == 2 assert data["cover_url"] == "https://example.com/storybook-cover.png" def test_generate_storybook_partial_image_failure(self, auth_client: TestClient): async def image_side_effect(prompt: str, **kwargs): if "singing firefly" in prompt: raise Exception("Image API error") slug = prompt.split()[0].lower() return f"https://example.com/{slug}.png" with patch("app.services.story_service.generate_storybook", new_callable=AsyncMock) as mock_storybook: with patch("app.services.story_service.generate_image", new_callable=AsyncMock) as mock_image: mock_storybook.return_value = build_storybook_output() mock_image.side_effect = image_side_effect response = auth_client.post( "/api/storybook/generate", json={ "keywords": "森林, 发光, 友情", "page_count": 6, "generate_images": True, }, ) assert response.status_code == 200 data = response.json() assert data["generation_status"] == "degraded_completed" assert data["image_status"] == "failed" assert data["audio_status"] == "not_requested" assert "第 2 页插图生成失败" in data["last_error"]