#
tokens: 11507/50000 12/12 files
lines: on (toggle) GitHub
raw markdown copy reset
# Directory Structure

```
├── .gitignore
├── LICENSE
├── logger.py
├── main.py
├── nijivoice
│   ├── __init__.py
│   ├── api.py
│   ├── exceptions.py
│   └── models.py
├── pyproject.toml
├── pytest.ini
├── README.md
├── run_mcp.py
├── server.py
├── test_api_client.py
├── tests
│   ├── __init__.py
│   ├── conftest.py
│   ├── mock_client.py
│   ├── README.md
│   ├── test_api.py
│   ├── test_exceptions.py
│   ├── test_models.py
│   └── test_server.py
└── uv.lock
```

# Files

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
 1 | # Python-generated files
 2 | __pycache__/
 3 | *.py[cod]
 4 | *$py.class
 5 | *.so
 6 | .Python
 7 | build/
 8 | develop-eggs/
 9 | dist/
10 | downloads/
11 | eggs/
12 | .eggs/
13 | lib/
14 | lib64/
15 | parts/
16 | sdist/
17 | var/
18 | wheels/
19 | *.egg-info/
20 | .installed.cfg
21 | *.egg
22 | 
23 | # Virtual environments
24 | .env
25 | .venv
26 | env/
27 | venv/
28 | ENV/
29 | env.bak/
30 | venv.bak/
31 | 
32 | # Testing
33 | .coverage
34 | .pytest_cache/
35 | htmlcov/
36 | .tox/
37 | coverage.xml
38 | *.cover
39 | .hypothesis/
40 | 
41 | # VSCode 
42 | .vscode/
43 | *.code-workspace
44 | 
45 | # Editor specific files
46 | *~
47 | .*.swp
48 | .DS_Store
49 | 
50 | # Local environment files
51 | .env
52 | .env:Zone.Identifier
53 | .python-version
54 | 
55 | **/.claude/settings.local.json
56 | 
```

--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------

```python
1 | """Test package for nijivoice-mcp."""
```

--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------

```
1 | [pytest]
2 | asyncio_mode = strict
3 | asyncio_default_fixture_loop_scope = function
```

--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------

```python
1 | def main():
2 |     print("Hello from nijivoice-mcp-beta!")
3 | 
4 | 
5 | if __name__ == "__main__":
6 |     main()
7 | 
```

--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------

```python
1 | """Configuration for pytest."""
2 | import pytest
3 | 
4 | # No need to redefine event_loop fixture
5 | # pytest-asyncio already provides this functionality
```

--------------------------------------------------------------------------------
/nijivoice/__init__.py:
--------------------------------------------------------------------------------

```python
1 | from .models import VoiceActor, VoiceGenerationRequest, Balance, RecommendedParameters
2 | from .api import NijiVoiceClient
3 | from .exceptions import NijiVoiceError, NijiVoiceAPIError
4 | 
5 | __version__ = "0.1.0"
```

--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------

```toml
 1 | [project]
 2 | name = "nijivoice-mcp-beta"
 3 | version = "0.1.0"
 4 | description = "Add your description here"
 5 | readme = "README.md"
 6 | requires-python = ">=3.12"
 7 | dependencies = [
 8 |     "fastmcp>=2.2.8",
 9 |     "httpx>=0.28.1",
10 |     "logger>=1.4",
11 |     "pytest>=8.3.5",
12 |     "pytest-asyncio>=0.26.0",
13 |     "pytest-cov>=4.1.0",
14 |     "python-dotenv>=1.1.0",
15 | ]
16 | 
```

--------------------------------------------------------------------------------
/tests/test_exceptions.py:
--------------------------------------------------------------------------------

```python
 1 | """Test suite for exceptions.py."""
 2 | import pytest
 3 | from nijivoice.exceptions import NijiVoiceError, NijiVoiceAPIError
 4 | 
 5 | 
 6 | class TestNijiVoiceExceptions:
 7 |     """Test cases for NijiVoice exceptions."""
 8 | 
 9 |     def test_nijivoice_error(self):
10 |         """Test NijiVoiceError base exception."""
11 |         # Create an instance
12 |         error = NijiVoiceError("Test error message")
13 |         
14 |         # Check if it's an instance of Exception
15 |         assert isinstance(error, Exception)
16 |         
17 |         # Check the error message
18 |         assert str(error) == "Test error message"
19 | 
20 |     def test_nijivoice_api_error_with_status_code(self):
21 |         """Test NijiVoiceAPIError with status code."""
22 |         # Create an instance with status code
23 |         error = NijiVoiceAPIError("API error message", status_code=404)
24 |         
25 |         # Check inheritance
26 |         assert isinstance(error, NijiVoiceError)
27 |         
28 |         # Check properties
29 |         assert str(error) == "API error message"
30 |         assert error.status_code == 404
31 | 
32 |     def test_nijivoice_api_error_without_status_code(self):
33 |         """Test NijiVoiceAPIError without status code."""
34 |         # Create an instance without status code
35 |         error = NijiVoiceAPIError("API error message")
36 |         
37 |         # Check properties
38 |         assert str(error) == "API error message"
39 |         assert error.status_code is None
```

--------------------------------------------------------------------------------
/tests/mock_client.py:
--------------------------------------------------------------------------------

```python
 1 | """Mock implementation of the NijiVoiceClient for testing."""
 2 | from typing import List, Optional, Dict, Any
 3 | from nijivoice.models import VoiceActor, VoiceGenerationRequest, Balance
 4 | from nijivoice.exceptions import NijiVoiceAPIError
 5 | 
 6 | class MockNijiVoiceClient:
 7 |     """Mock implementation of NijiVoiceClient for testing."""
 8 |     
 9 |     def __init__(self, api_key: Optional[str] = None, timeout: Optional[float] = 30.0):
10 |         """Mock initialization."""
11 |         self.api_key = api_key
12 |         self.timeout = timeout
13 |         self.should_fail = False
14 |         self.headers = {
15 |             "x-api-key": api_key or "",
16 |             "Accept": "application/json",
17 |         }
18 |         self.voice_actors = [
19 |             VoiceActor(
20 |                 id="voice-actor-1",
21 |                 name="Test Actor 1",
22 |                 description="Test description",
23 |                 gender="Male",
24 |                 age=25,
25 |             ),
26 |             VoiceActor(
27 |                 id="voice-actor-2",
28 |                 name="Test Actor 2",
29 |                 description="Another test description",
30 |                 gender="Female",
31 |                 age=30,
32 |             ),
33 |         ]
34 |     
35 |     def set_should_fail(self, should_fail: bool):
36 |         """Set whether API calls should fail."""
37 |         self.should_fail = should_fail
38 |     
39 |     async def get_voice_actors(self) -> List[VoiceActor]:
40 |         """Mock implementation of get_voice_actors."""
41 |         if self.should_fail:
42 |             raise NijiVoiceAPIError("Failed to get voice actors", status_code=500)
43 |         return self.voice_actors
44 |     
45 |     async def generate_voice(self, request: VoiceGenerationRequest) -> Dict[str, Any]:
46 |         """Mock implementation of generate_voice."""
47 |         if self.should_fail:
48 |             raise NijiVoiceAPIError("Failed to generate voice", status_code=500)
49 |         return {
50 |             "encoded_voice": "base64_encoded_mock_audio_data",
51 |             "remaining_credits": 100,
52 |             "format": request.format,
53 |             "actor_id": request.id
54 |         }
55 |     
56 |     async def get_balance(self) -> Balance:
57 |         """Mock implementation of get_balance."""
58 |         if self.should_fail:
59 |             raise NijiVoiceAPIError("Failed to get balance", status_code=500)
60 |         return Balance(balance=100)
```

--------------------------------------------------------------------------------
/tests/test_server.py:
--------------------------------------------------------------------------------

```python
  1 | """Test suite for server.py."""
  2 | import os
  3 | import pytest
  4 | from unittest.mock import patch, MagicMock, AsyncMock
  5 | import asyncio
  6 | from fastmcp import FastMCP
  7 | 
  8 | from nijivoice.models import VoiceActor, VoiceGenerationRequest, Balance
  9 | from nijivoice.exceptions import NijiVoiceAPIError
 10 | from tests.mock_client import MockNijiVoiceClient
 11 | 
 12 | # Patch environment to avoid loading actual API keys
 13 | @pytest.fixture(autouse=True)
 14 | def mock_env():
 15 |     """Mock environment variables."""
 16 |     with patch.dict(os.environ, {"NIJIVOICE_API_KEY": "test-api-key"}):
 17 |         yield
 18 | 
 19 | @pytest.fixture
 20 | def mock_client():
 21 |     """Create a mock NijiVoiceClient."""
 22 |     client = MockNijiVoiceClient(api_key="test-api-key")
 23 |     return client
 24 | 
 25 | @pytest.fixture
 26 | def server_module():
 27 |     """Import the server module with patched client."""
 28 |     with patch("nijivoice.api.NijiVoiceClient", MockNijiVoiceClient):
 29 |         import server
 30 |         yield server
 31 | 
 32 | @pytest.mark.asyncio
 33 | async def test_get_voice_actors(server_module, mock_client):
 34 |     """Test get_voice_actors function returns list of voice actors."""
 35 |     # Patch the client in server module
 36 |     server_module.client = mock_client
 37 |     
 38 |     # Get the tool function
 39 |     get_voice_actors = server_module.get_voice_actors
 40 |     
 41 |     # Call the function
 42 |     result = await get_voice_actors()
 43 |     
 44 |     # Check if result is a list of VoiceActor objects
 45 |     assert isinstance(result, list)
 46 |     assert all(isinstance(actor, VoiceActor) for actor in result)
 47 |     assert len(result) == 2
 48 |     assert result[0].id == "voice-actor-1"
 49 |     assert result[1].id == "voice-actor-2"
 50 | 
 51 | @pytest.mark.asyncio
 52 | async def test_get_voice_actors_error(server_module, mock_client):
 53 |     """Test error handling in get_voice_actors function."""
 54 |     # Set client to fail
 55 |     mock_client.set_should_fail(True)
 56 |     
 57 |     # Patch the client in server module
 58 |     server_module.client = mock_client
 59 |     
 60 |     # Get the tool function
 61 |     get_voice_actors = server_module.get_voice_actors
 62 |     
 63 |     # Call the function and expect an exception
 64 |     with pytest.raises(NijiVoiceAPIError) as excinfo:
 65 |         await get_voice_actors()
 66 |     
 67 |     # Check the exception details
 68 |     assert excinfo.value.status_code == 500
 69 |     assert "Failed to get voice actors" in str(excinfo.value)
 70 | 
 71 | @pytest.mark.asyncio
 72 | async def test_mcp_tools_registration(server_module):
 73 |     """Test that the tool is properly registered with FastMCP."""
 74 |     mcp = server_module.mcp
 75 |     
 76 |     # Check if FastMCP instance is created
 77 |     assert isinstance(mcp, FastMCP)
 78 |     assert mcp.name == "nijivoice MCP"
 79 |     
 80 |     # Check if the get_voice_actors function exists and is decorated
 81 |     assert hasattr(server_module, "get_voice_actors")
 82 |     get_voice_actors = server_module.get_voice_actors
 83 |     
 84 |     # Check if function has the expected docstring
 85 |     assert "Get the list of voice actors." in get_voice_actors.__doc__
 86 | 
 87 | @pytest.mark.asyncio
 88 | async def test_client_initialization(server_module):
 89 |     """Test that the NijiVoiceClient is initialized properly."""
 90 |     # Check that the client is initialized
 91 |     client = server_module.client
 92 |     assert isinstance(client, MockNijiVoiceClient)
 93 |     assert client.api_key == "test-api-key"
 94 | 
 95 | @pytest.mark.asyncio
 96 | async def test_dotenv_loading():
 97 |     """Test that dotenv is loaded properly."""
 98 |     # Create a mock for load_dotenv
 99 |     with patch("dotenv.load_dotenv") as mock_load_dotenv:
100 |         # Import the server module to trigger load_dotenv
101 |         with patch("nijivoice.api.NijiVoiceClient"):
102 |             import importlib
103 |             import sys
104 |             
105 |             # Remove the module from sys.modules if it exists
106 |             if "server" in sys.modules:
107 |                 del sys.modules["server"]
108 |                 
109 |             # Re-import the module
110 |             import server
111 |             
112 |             # Check that load_dotenv was called
113 |             mock_load_dotenv.assert_called_once()
114 | 
115 | @pytest.mark.asyncio
116 | async def test_generate_voice(server_module, mock_client):
117 |     """Test generate_voice function."""
118 |     # Setup
119 |     server_module.client = mock_client
120 |     
121 |     # Create a detailed mock for client.generate_voice
122 |     mock_response = {"encoded_voice": "mock_data", "credits": 100}
123 |     mock_client.generate_voice = AsyncMock(return_value=mock_response)
124 |     
125 |     # Replace get_voice_actors with a mock
126 |     mock_actors = [
127 |         VoiceActor(id="test-actor-1", name="Test Actor 1"),
128 |         VoiceActor(id="test-actor-2", name="Test Actor 2")
129 |     ]
130 |     server_module.get_voice_actors = AsyncMock(return_value=mock_actors)
131 |     
132 |     # Call the function
133 |     result = await server_module.generate_voice(script="こんにちは")
134 |     
135 |     # Verify
136 |     server_module.get_voice_actors.assert_called_once()
137 |     
138 |     # Check that the client's generate_voice was called with the correct request
139 |     called_request = mock_client.generate_voice.call_args[1]["request"]
140 |     assert isinstance(called_request, VoiceGenerationRequest)
141 |     assert called_request.script == "こんにちは"
142 |     
143 |     # Assert the result matches the mock response
144 |     assert result == mock_response
145 | 
146 | @pytest.mark.asyncio
147 | async def test_generate_voice_no_actors(server_module, mock_client):
148 |     """Test generate_voice function when no actors are available."""
149 |     # Setup
150 |     server_module.client = mock_client
151 |     
152 |     # Replace get_voice_actors with a mock that returns empty list
153 |     server_module.get_voice_actors = AsyncMock(return_value=[])
154 |     
155 |     # Call the function
156 |     result = await server_module.generate_voice(script="こんにちは")
157 |     
158 |     # Verify
159 |     server_module.get_voice_actors.assert_called_once()
160 |     
161 |     # The result should indicate an error
162 |     assert result["status"] == "error"
163 |     assert "利用可能なVoice Actorが見つかりません" in result["message"]
164 | 
165 | @pytest.mark.asyncio
166 | async def test_get_credit_balance(server_module, mock_client):
167 |     """Test get_credit_balance function."""
168 |     # Setup
169 |     server_module.client = mock_client
170 |     mock_balance = Balance(balance=200)
171 |     mock_client.get_balance = AsyncMock(return_value=mock_balance)
172 |     
173 |     # Call the function
174 |     result = await server_module.get_credit_balance()
175 |     
176 |     # Verify
177 |     mock_client.get_balance.assert_called_once()
178 |     assert result == 200
179 | 
180 | @pytest.mark.asyncio
181 | async def test_voice_actors_resource(server_module, mock_client):
182 |     """Test voice_actors_resource function."""
183 |     # Setup
184 |     server_module.client = mock_client
185 |     
186 |     # Replace get_voice_actors with a mock
187 |     mock_actors = [
188 |         VoiceActor(id="test-actor-1", name="Test Actor 1"),
189 |         VoiceActor(id="test-actor-2", name="Test Actor 2")
190 |     ]
191 |     server_module.get_voice_actors = AsyncMock(return_value=mock_actors)
192 |     
193 |     # Call the function
194 |     result = await server_module.voice_actors_resource()
195 |     
196 |     # Verify
197 |     server_module.get_voice_actors.assert_called_once()
198 |     assert result == mock_actors
199 | 
200 | @pytest.mark.asyncio
201 | async def test_voice_actor_resource(server_module, mock_client):
202 |     """Test voice_actor_resource function."""
203 |     # Setup
204 |     server_module.client = mock_client
205 |     
206 |     # Replace get_voice_actors with a mock
207 |     mock_actors = [
208 |         VoiceActor(id="test-actor-1", name="Test Actor 1"),
209 |         VoiceActor(id="test-actor-2", name="Test Actor 2")
210 |     ]
211 |     server_module.get_voice_actors = AsyncMock(return_value=mock_actors)
212 |     
213 |     # Call the function with existing actor ID
214 |     result = await server_module.voice_actor_resource(actor_id="test-actor-1")
215 |     
216 |     # Verify
217 |     server_module.get_voice_actors.assert_called_once()
218 |     assert result == mock_actors[0]
219 |     
220 |     # Reset the mock
221 |     server_module.get_voice_actors.reset_mock()
222 |     
223 |     # Call the function with non-existing actor ID
224 |     result = await server_module.voice_actor_resource(actor_id="non-existing")
225 |     
226 |     # Verify
227 |     server_module.get_voice_actors.assert_called_once()
228 |     assert result is None
229 | 
230 | @pytest.mark.asyncio
231 | async def test_credit_balance_resource(server_module, mock_client):
232 |     """Test credit_balance_resource function."""
233 |     # Setup
234 |     server_module.client = mock_client
235 |     mock_balance = Balance(balance=200)
236 |     mock_client.get_balance = AsyncMock(return_value=mock_balance)
237 |     
238 |     # Call the function
239 |     result = await server_module.credit_balance_resource()
240 |     
241 |     # Verify
242 |     mock_client.get_balance.assert_called_once()
243 |     assert result == mock_balance
244 | 
245 | @pytest.mark.asyncio
246 | async def test_voice_generation_prompt(server_module):
247 |     """Test voice_generation_prompt function."""
248 |     # Call the function
249 |     result = server_module.voice_generation_prompt()
250 |     
251 |     # Verify
252 |     assert isinstance(result, str)
253 |     assert "にじボイス音声生成" in result
254 |     assert "利用可能なVoice Actor" in result
```

--------------------------------------------------------------------------------
/tests/test_models.py:
--------------------------------------------------------------------------------

```python
  1 | """Test suite for models.py."""
  2 | import pytest
  3 | from pydantic import ValidationError
  4 | 
  5 | from nijivoice.models import (
  6 |     RecommendedParameters,
  7 |     VoiceStyle,
  8 |     VoiceActor,
  9 |     VoiceGenerationRequest,
 10 |     Balance
 11 | )
 12 | 
 13 | 
 14 | class TestRecommendedParameters:
 15 |     """Tests for RecommendedParameters model."""
 16 | 
 17 |     def test_create_recommended_parameters(self):
 18 |         """Test creating RecommendedParameters with default values."""
 19 |         params = RecommendedParameters()
 20 |         assert params.emotional_level == 1.0
 21 |         assert params.sound_duration == 1.0
 22 | 
 23 |     def test_create_with_values(self):
 24 |         """Test creating RecommendedParameters with values."""
 25 |         params = RecommendedParameters(
 26 |             emotional_level=0.8,
 27 |             sound_duration=1.2
 28 |         )
 29 |         assert params.emotional_level == 0.8
 30 |         assert params.sound_duration == 1.2
 31 | 
 32 |     def test_create_with_aliases(self):
 33 |         """Test creating RecommendedParameters using alias names."""
 34 |         params = RecommendedParameters(
 35 |             emotionalLevel=0.8,
 36 |             soundDuration=1.2
 37 |         )
 38 |         assert params.emotional_level == 0.8
 39 |         assert params.sound_duration == 1.2
 40 | 
 41 |     def test_ignore_extra_fields(self):
 42 |         """Test that extra fields are ignored."""
 43 |         params = RecommendedParameters(
 44 |             emotional_level=0.8,
 45 |             sound_duration=1.2,
 46 |             extra_field="should be ignored"
 47 |         )
 48 |         assert not hasattr(params, "extra_field")
 49 | 
 50 | 
 51 | class TestVoiceStyle:
 52 |     """Tests for VoiceStyle model."""
 53 | 
 54 |     def test_create_voice_style(self):
 55 |         """Test creating VoiceStyle."""
 56 |         style = VoiceStyle(id=1, style="normal")
 57 |         assert style.id == 1
 58 |         assert style.style == "normal"
 59 | 
 60 |     def test_voice_style_validation(self):
 61 |         """Test VoiceStyle validation."""
 62 |         # Missing required field should raise ValidationError
 63 |         with pytest.raises(ValidationError):
 64 |             VoiceStyle(style="normal")  # Missing id
 65 |         
 66 |         with pytest.raises(ValidationError):
 67 |             VoiceStyle(id=1)  # Missing style
 68 | 
 69 | 
 70 | class TestVoiceActor:
 71 |     """Tests for VoiceActor model."""
 72 | 
 73 |     def test_create_voice_actor_minimal(self):
 74 |         """Test creating VoiceActor with minimal required fields."""
 75 |         actor = VoiceActor(id="actor-1", name="Test Actor")
 76 |         assert actor.id == "actor-1"
 77 |         assert actor.name == "Test Actor"
 78 |         assert actor.description == ""  # Default value
 79 |         assert actor.gender is None
 80 |         assert actor.age is None
 81 | 
 82 |     def test_create_voice_actor_full(self):
 83 |         """Test creating VoiceActor with all fields."""
 84 |         actor = VoiceActor(
 85 |             id="actor-1",
 86 |             name="Test Actor",
 87 |             name_reading="テストアクター",
 88 |             age=25,
 89 |             gender="Female",
 90 |             birth_month=7,
 91 |             birth_day=15,
 92 |             description="Test description",
 93 |             small_image_url="https://example.com/small.jpg",
 94 |             medium_image_url="https://example.com/medium.jpg",
 95 |             large_image_url="https://example.com/large.jpg",
 96 |             sample_voice_url="https://example.com/sample.mp3",
 97 |             sample_script="こんにちは",
 98 |             recommended_voice_speed=1.2,
 99 |             recommended_emotional_level=0.8,
100 |             recommended_sound_duration=1.0,
101 |             recommended_parameters=RecommendedParameters(
102 |                 emotional_level=0.8,
103 |                 sound_duration=1.0
104 |             ),
105 |             voice_styles=[
106 |                 VoiceStyle(id=1, style="normal"),
107 |                 VoiceStyle(id=2, style="happy")
108 |             ]
109 |         )
110 |         
111 |         assert actor.id == "actor-1"
112 |         assert actor.name == "Test Actor"
113 |         assert actor.name_reading == "テストアクター"
114 |         assert actor.age == 25
115 |         assert actor.gender == "Female"
116 |         assert actor.birth_month == 7
117 |         assert actor.birth_day == 15
118 |         assert actor.description == "Test description"
119 |         assert actor.small_image_url == "https://example.com/small.jpg"
120 |         assert actor.medium_image_url == "https://example.com/medium.jpg"
121 |         assert actor.large_image_url == "https://example.com/large.jpg"
122 |         assert actor.sample_voice_url == "https://example.com/sample.mp3"
123 |         assert actor.sample_script == "こんにちは"
124 |         assert actor.recommended_voice_speed == 1.2
125 |         assert actor.recommended_emotional_level == 0.8
126 |         assert actor.recommended_sound_duration == 1.0
127 |         assert isinstance(actor.recommended_parameters, RecommendedParameters)
128 |         assert len(actor.voice_styles) == 2
129 |         assert actor.voice_styles[0].id == 1
130 |         assert actor.voice_styles[0].style == "normal"
131 | 
132 |     def test_create_with_aliases(self):
133 |         """Test creating VoiceActor using alias names."""
134 |         actor = VoiceActor(
135 |             id="actor-1",
136 |             name="Test Actor",
137 |             nameReading="テストアクター",
138 |             birthMonth=7,
139 |             birthDay=15,
140 |             smallImageUrl="https://example.com/small.jpg",
141 |             mediumImageUrl="https://example.com/medium.jpg",
142 |             largeImageUrl="https://example.com/large.jpg",
143 |             sampleVoiceUrl="https://example.com/sample.mp3",
144 |             sampleScript="こんにちは",
145 |             recommendedVoiceSpeed=1.2,
146 |             recommendedEmotionalLevel=0.8,
147 |             recommendedSoundDuration=1.0,
148 |             recommendedParameters={
149 |                 "emotionalLevel": 0.8,
150 |                 "soundDuration": 1.0
151 |             },
152 |             voiceStyles=[
153 |                 {"id": 1, "style": "normal"},
154 |                 {"id": 2, "style": "happy"}
155 |             ]
156 |         )
157 |         
158 |         assert actor.name_reading == "テストアクター"
159 |         assert actor.birth_month == 7
160 |         assert actor.birth_day == 15
161 |         assert actor.small_image_url == "https://example.com/small.jpg"
162 |         assert isinstance(actor.recommended_parameters, RecommendedParameters)
163 |         assert len(actor.voice_styles) == 2
164 | 
165 | 
166 | class TestVoiceGenerationRequest:
167 |     """Tests for VoiceGenerationRequest model."""
168 | 
169 |     def test_create_minimal_request(self):
170 |         """Test creating VoiceGenerationRequest with minimal fields."""
171 |         request = VoiceGenerationRequest(
172 |             id="actor-1",
173 |             script="こんにちは"
174 |         )
175 |         
176 |         assert request.id == "actor-1"
177 |         assert request.script == "こんにちは"
178 |         assert request.speed == 1.0  # Default value
179 |         assert request.emotional_level is None
180 |         assert request.sound_duration is None
181 |         assert request.format == "mp3"  # Default value
182 | 
183 |     def test_create_full_request(self):
184 |         """Test creating VoiceGenerationRequest with all fields."""
185 |         request = VoiceGenerationRequest(
186 |             id="actor-1",
187 |             script="こんにちは",
188 |             speed=1.5,
189 |             emotional_level=0.8,
190 |             sound_duration=1.2,
191 |             format="wav"
192 |         )
193 |         
194 |         assert request.id == "actor-1"
195 |         assert request.script == "こんにちは"
196 |         assert request.speed == 1.5
197 |         assert request.emotional_level == 0.8
198 |         assert request.sound_duration == 1.2
199 |         assert request.format == "wav"
200 | 
201 |     def test_create_request_with_aliases(self):
202 |         """Test creating VoiceGenerationRequest with aliases."""
203 |         request = VoiceGenerationRequest(
204 |             id="actor-1",
205 |             script="こんにちは",
206 |             speed=1.5,
207 |             emotionalLevel=0.8,
208 |             soundDuration=1.2
209 |         )
210 |         
211 |         assert request.emotional_level == 0.8
212 |         assert request.sound_duration == 1.2
213 | 
214 |     def test_speed_validation(self):
215 |         """Test speed validation."""
216 |         # Valid values
217 |         request = VoiceGenerationRequest(id="actor-1", script="こんにちは", speed=0.4)
218 |         assert request.speed == 0.4
219 |         
220 |         request = VoiceGenerationRequest(id="actor-1", script="こんにちは", speed=3.0)
221 |         assert request.speed == 3.0
222 |         
223 |         # Invalid values
224 |         with pytest.raises(ValidationError):
225 |             VoiceGenerationRequest(id="actor-1", script="こんにちは", speed=0.3)  # Too low
226 |         
227 |         with pytest.raises(ValidationError):
228 |             VoiceGenerationRequest(id="actor-1", script="こんにちは", speed=3.1)  # Too high
229 | 
230 |     def test_emotional_level_validation(self):
231 |         """Test emotional_level validation."""
232 |         # Valid values
233 |         request = VoiceGenerationRequest(id="actor-1", script="こんにちは", emotional_level=0.0)
234 |         assert request.emotional_level == 0.0
235 |         
236 |         request = VoiceGenerationRequest(id="actor-1", script="こんにちは", emotional_level=1.5)
237 |         assert request.emotional_level == 1.5
238 |         
239 |         # Invalid values
240 |         with pytest.raises(ValidationError):
241 |             VoiceGenerationRequest(id="actor-1", script="こんにちは", emotional_level=-0.1)  # Too low
242 |         
243 |         with pytest.raises(ValidationError):
244 |             VoiceGenerationRequest(id="actor-1", script="こんにちは", emotional_level=1.6)  # Too high
245 | 
246 |     def test_format_validation(self):
247 |         """Test format validation."""
248 |         # Valid values
249 |         request = VoiceGenerationRequest(id="actor-1", script="こんにちは", format="mp3")
250 |         assert request.format == "mp3"
251 |         
252 |         request = VoiceGenerationRequest(id="actor-1", script="こんにちは", format="wav")
253 |         assert request.format == "wav"
254 |         
255 |         # Invalid values
256 |         with pytest.raises(ValidationError):
257 |             VoiceGenerationRequest(id="actor-1", script="こんにちは", format="flac")
258 | 
259 |     def test_serializers(self):
260 |         """Test field serializers."""
261 |         request = VoiceGenerationRequest(
262 |             id="actor-1",
263 |             script="こんにちは",
264 |             speed=1.5,
265 |             emotional_level=0.8,
266 |             sound_duration=1.2,
267 |             format="wav"  # Already lowercase since validation happens before serialization
268 |         )
269 |         
270 |         data = request.model_dump(by_alias=True)
271 |         
272 |         # Check serialized values
273 |         assert data["speed"] == "1.5"  # Serialized as string
274 |         assert data["emotionalLevel"] == "0.8"  # Serialized as string
275 |         assert data["soundDuration"] == "1.2"  # Serialized as string
276 |         assert data["format"] == "wav"  # Lowercase conversion
277 | 
278 | 
279 | class TestBalance:
280 |     """Tests for Balance model."""
281 | 
282 |     def test_create_simple_balance(self):
283 |         """Test creating Balance with simple structure."""
284 |         balance = Balance(balance=500)
285 |         assert balance.balance == 500
286 |         assert balance.balances is None
287 |         assert balance.get_credit() == 500
288 | 
289 |     def test_create_complex_balance(self):
290 |         """Test creating Balance with complex structure."""
291 |         balance = Balance(balances={
292 |             "remainingBalance": 800,
293 |             "credits": [
294 |                 {"balance": 500, "type": "regular"},
295 |                 {"balance": 300, "type": "bonus"}
296 |             ]
297 |         })
298 |         
299 |         assert balance.balance is None
300 |         assert isinstance(balance.balances, dict)
301 |         assert balance.get_credit() == 800  # Should get remainingBalance
302 | 
303 |     def test_get_credit_with_balance_field(self):
304 |         """Test get_credit with balance field."""
305 |         balance = Balance(balance=500)
306 |         assert balance.get_credit() == 500
307 | 
308 |     def test_get_credit_with_remaining_balance(self):
309 |         """Test get_credit with remainingBalance in balances."""
310 |         balance = Balance(balances={"remainingBalance": 800})
311 |         assert balance.get_credit() == 800
312 | 
313 |     def test_get_credit_with_balance_in_balances(self):
314 |         """Test get_credit with balance in balances."""
315 |         balance = Balance(balances={"balance": 700})
316 |         assert balance.get_credit() == 700
317 | 
318 |     def test_get_credit_with_credits_list(self):
319 |         """Test get_credit with credits list in balances."""
320 |         balance = Balance(balances={
321 |             "credits": [
322 |                 {"balance": 500, "type": "regular"},
323 |                 {"balance": 300, "type": "bonus"}
324 |             ]
325 |         })
326 |         assert balance.get_credit() == 500  # Should get first credit balance
327 | 
328 |     def test_get_credit_with_no_balance_info(self):
329 |         """Test get_credit with no balance information."""
330 |         balance = Balance(balances={"other": "value"})
331 |         assert balance.get_credit() == 0  # Should return 0 if no balance info
332 | 
333 |     def test_get_credit_with_empty_balance(self):
334 |         """Test get_credit with empty Balance."""
335 |         balance = Balance()
336 |         assert balance.get_credit() == 0
```

--------------------------------------------------------------------------------
/tests/test_api.py:
--------------------------------------------------------------------------------

```python
  1 | """Test suite for NijiVoiceClient in api.py."""
  2 | import os
  3 | import pytest
  4 | import json
  5 | from unittest.mock import patch, AsyncMock, MagicMock
  6 | 
  7 | import httpx
  8 | import pytest_asyncio
  9 | 
 10 | from nijivoice.api import NijiVoiceClient
 11 | from nijivoice.models import VoiceActor, VoiceGenerationRequest, Balance
 12 | from nijivoice.exceptions import NijiVoiceAPIError
 13 | 
 14 | 
 15 | @pytest.fixture
 16 | def mock_env():
 17 |     """Mock environment variables."""
 18 |     with patch.dict(os.environ, {"NIJIVOICE_API_KEY": "test-api-key"}):
 19 |         yield
 20 | 
 21 | 
 22 | @pytest.fixture
 23 | def mock_response():
 24 |     """Create a mock response."""
 25 |     mock = MagicMock()
 26 |     mock.status_code = 200
 27 |     mock.text = "Success"
 28 |     return mock
 29 | 
 30 | 
 31 | @pytest.fixture
 32 | def client():
 33 |     """Create a NijiVoiceClient instance."""
 34 |     return NijiVoiceClient(api_key="test-api-key")
 35 | 
 36 | 
 37 | class TestNijiVoiceClient:
 38 |     """Test cases for NijiVoiceClient."""
 39 | 
 40 |     def test_init_with_api_key(self):
 41 |         """Test initialization with api_key parameter."""
 42 |         client = NijiVoiceClient(api_key="test-api-key")
 43 |         assert client.api_key == "test-api-key"
 44 |         assert client.timeout == 30.0
 45 |         assert client.headers == {
 46 |             "x-api-key": "test-api-key",
 47 |             "Accept": "application/json",
 48 |         }
 49 | 
 50 |     def test_init_without_api_key(self, mock_env):
 51 |         """Test initialization without api_key parameter."""
 52 |         client = NijiVoiceClient()
 53 |         assert client.api_key == "test-api-key"
 54 |         assert client.timeout == 30.0
 55 | 
 56 |     def test_init_with_custom_timeout(self):
 57 |         """Test initialization with custom timeout."""
 58 |         client = NijiVoiceClient(api_key="test-api-key", timeout=60.0)
 59 |         assert client.timeout == 60.0
 60 | 
 61 |     def test_init_without_api_key_and_env(self):
 62 |         """Test initialization without api_key parameter and environment variable."""
 63 |         with patch.dict(os.environ, {}, clear=True):
 64 |             with pytest.raises(ValueError) as excinfo:
 65 |                 NijiVoiceClient()
 66 |             assert "APIキーが指定されていません" in str(excinfo.value)
 67 | 
 68 |     @pytest.mark.asyncio
 69 |     async def test_get_voice_actors_success(self, client, mock_response):
 70 |         """Test get_voice_actors successful response."""
 71 |         # Create mock data
 72 |         voice_actors_data = [
 73 |             {
 74 |                 "id": "actor-id-1",
 75 |                 "name": "Actor 1",
 76 |                 "gender": "Female",
 77 |                 "age": 25
 78 |             },
 79 |             {
 80 |                 "id": "actor-id-2",
 81 |                 "name": "Actor 2",
 82 |                 "gender": "Male",
 83 |                 "age": 30
 84 |             }
 85 |         ]
 86 |         mock_response.json.return_value = voice_actors_data
 87 | 
 88 |         # Mock the AsyncClient.get method
 89 |         with patch("httpx.AsyncClient") as mock_client:
 90 |             mock_client.return_value.__aenter__.return_value.get.return_value = mock_response
 91 |             
 92 |             # Call the method
 93 |             result = await client.get_voice_actors()
 94 |             
 95 |             # Verify the result
 96 |             assert len(result) == 2
 97 |             assert isinstance(result[0], VoiceActor)
 98 |             assert result[0].id == "actor-id-1"
 99 |             assert result[0].name == "Actor 1"
100 |             assert result[1].id == "actor-id-2"
101 |             assert result[1].name == "Actor 2"
102 |             
103 |             # Verify the request was made correctly
104 |             mock_client.return_value.__aenter__.return_value.get.assert_called_once_with(
105 |                 f"{client.BASE_URL}/voice-actors", 
106 |                 headers=client.headers
107 |             )
108 | 
109 |     @pytest.mark.asyncio
110 |     async def test_get_voice_actors_voiceActors_key(self, client, mock_response):
111 |         """Test get_voice_actors with voiceActors key in response."""
112 |         # Create mock data with voiceActors key
113 |         voice_actors_data = {
114 |             "voiceActors": [
115 |                 {
116 |                     "id": "actor-id-1",
117 |                     "name": "Actor 1",
118 |                     "gender": "Female",
119 |                     "age": 25
120 |                 },
121 |                 {
122 |                     "id": "actor-id-2",
123 |                     "name": "Actor 2",
124 |                     "gender": "Male",
125 |                     "age": 30
126 |                 }
127 |             ]
128 |         }
129 |         mock_response.json.return_value = voice_actors_data
130 | 
131 |         # Mock the AsyncClient.get method
132 |         with patch("httpx.AsyncClient") as mock_client:
133 |             mock_client.return_value.__aenter__.return_value.get.return_value = mock_response
134 |             
135 |             # Call the method
136 |             result = await client.get_voice_actors()
137 |             
138 |             # Verify the result
139 |             assert len(result) == 2
140 |             assert isinstance(result[0], VoiceActor)
141 |             assert result[0].id == "actor-id-1"
142 |             assert result[0].name == "Actor 1"
143 | 
144 |     @pytest.mark.asyncio
145 |     async def test_get_voice_actors_unexpected_format(self, client, mock_response):
146 |         """Test get_voice_actors with unexpected data format."""
147 |         # Create unexpected data format
148 |         mock_response.json.return_value = {"unexpectedKey": "unexpected value"}
149 | 
150 |         # Mock the AsyncClient.get method
151 |         with patch("httpx.AsyncClient") as mock_client:
152 |             mock_client.return_value.__aenter__.return_value.get.return_value = mock_response
153 |             
154 |             # Call the method
155 |             result = await client.get_voice_actors()
156 |             
157 |             # Verify the result is an empty list
158 |             assert isinstance(result, list)
159 |             assert len(result) == 0
160 | 
161 |     @pytest.mark.asyncio
162 |     async def test_get_voice_actors_error_response(self, client):
163 |         """Test get_voice_actors with error response."""
164 |         # Create error response
165 |         error_response = MagicMock()
166 |         error_response.status_code = 404
167 |         error_response.text = "Not Found"
168 | 
169 |         # Mock the AsyncClient.get method
170 |         with patch("httpx.AsyncClient") as mock_client:
171 |             mock_client.return_value.__aenter__.return_value.get.return_value = error_response
172 |             
173 |             # Call the method and expect exception
174 |             with pytest.raises(NijiVoiceAPIError) as excinfo:
175 |                 await client.get_voice_actors()
176 |             
177 |             # Verify the exception
178 |             assert excinfo.value.status_code == 404
179 |             assert "Voice Actor一覧の取得に失敗しました" in str(excinfo.value)
180 | 
181 |     @pytest.mark.asyncio
182 |     async def test_get_voice_actors_timeout(self, client):
183 |         """Test get_voice_actors with timeout."""
184 |         # Mock the AsyncClient.get method to raise TimeoutError
185 |         with patch("httpx.AsyncClient") as mock_client:
186 |             mock_client.return_value.__aenter__.return_value.get.side_effect = TimeoutError("Timeout")
187 |             
188 |             # Call the method and expect exception
189 |             with pytest.raises(NijiVoiceAPIError) as excinfo:
190 |                 await client.get_voice_actors()
191 |             
192 |             # Verify the exception
193 |             assert excinfo.value.status_code == 408
194 |             assert "APIリクエストがタイムアウトしました" in str(excinfo.value)
195 | 
196 |     @pytest.mark.asyncio
197 |     async def test_get_voice_actors_general_exception(self, client):
198 |         """Test get_voice_actors with general exception."""
199 |         # Mock the AsyncClient.get method to raise Exception
200 |         with patch("httpx.AsyncClient") as mock_client:
201 |             mock_client.return_value.__aenter__.return_value.get.side_effect = Exception("Some error")
202 |             
203 |             # Call the method and expect exception
204 |             with pytest.raises(NijiVoiceAPIError) as excinfo:
205 |                 await client.get_voice_actors()
206 |             
207 |             # Verify the exception
208 |             assert excinfo.value.status_code == 500
209 |             assert "API呼び出し中にエラーが発生しました" in str(excinfo.value)
210 | 
211 |     @pytest.mark.asyncio
212 |     async def test_generate_voice_success(self, client, mock_response):
213 |         """Test generate_voice successful response."""
214 |         # Create mock response data
215 |         response_data = {
216 |             "encoded_voice": "base64_encoded_data",
217 |             "remaining_credits": 100
218 |         }
219 |         mock_response.json.return_value = response_data
220 | 
221 |         # Create request
222 |         request = VoiceGenerationRequest(
223 |             id="actor-id-1",
224 |             script="これはテストです",
225 |             speed=1.0
226 |         )
227 | 
228 |         # Mock the AsyncClient.post method
229 |         with patch("httpx.AsyncClient") as mock_client:
230 |             mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
231 |             
232 |             # Call the method
233 |             result = await client.generate_voice(request=request)
234 |             
235 |             # Verify the result
236 |             assert result == response_data
237 |             
238 |             # Verify the request was made correctly
239 |             mock_client.return_value.__aenter__.return_value.post.assert_called_once_with(
240 |                 f"{client.BASE_URL}/voice-actors/{request.id}/generate-voice",
241 |                 headers=client.headers,
242 |                 json=request.model_dump(by_alias=True)
243 |             )
244 | 
245 |     @pytest.mark.asyncio
246 |     async def test_generate_voice_error_response(self, client):
247 |         """Test generate_voice with error response."""
248 |         # Create error response
249 |         error_response = MagicMock()
250 |         error_response.status_code = 500
251 |         error_response.text = "Server Error"
252 | 
253 |         # Create request
254 |         request = VoiceGenerationRequest(
255 |             id="actor-id-1",
256 |             script="これはテストです",
257 |             speed=1.0
258 |         )
259 | 
260 |         # Mock the AsyncClient.post method
261 |         with patch("httpx.AsyncClient") as mock_client:
262 |             mock_client.return_value.__aenter__.return_value.post.return_value = error_response
263 |             
264 |             # Call the method and expect exception
265 |             with pytest.raises(NijiVoiceAPIError) as excinfo:
266 |                 await client.generate_voice(request=request)
267 |             
268 |             # Verify the exception
269 |             assert excinfo.value.status_code == 500
270 |             assert "音声生成に失敗しました" in str(excinfo.value)
271 | 
272 |     @pytest.mark.asyncio
273 |     async def test_get_balance_success(self, client, mock_response):
274 |         """Test get_balance successful response."""
275 |         # Create mock response data
276 |         response_data = {"balance": 500}
277 |         mock_response.json.return_value = response_data
278 | 
279 |         # Mock the AsyncClient.get method
280 |         with patch("httpx.AsyncClient") as mock_client:
281 |             mock_client.return_value.__aenter__.return_value.get.return_value = mock_response
282 |             
283 |             # Call the method
284 |             result = await client.get_balance()
285 |             
286 |             # Verify the result
287 |             assert isinstance(result, Balance)
288 |             assert result.balance == 500
289 |             assert result.get_credit() == 500
290 |             
291 |             # Verify the request was made correctly
292 |             mock_client.return_value.__aenter__.return_value.get.assert_called_once_with(
293 |                 f"{client.BASE_URL}/balances",
294 |                 headers=client.headers
295 |             )
296 | 
297 |     @pytest.mark.asyncio
298 |     async def test_get_balance_complex_response(self, client, mock_response):
299 |         """Test get_balance with complex response structure."""
300 |         # Create mock response data with complex structure
301 |         response_data = {
302 |             "balances": {
303 |                 "remainingBalance": 500,
304 |                 "credits": [
305 |                     {"balance": 300, "type": "regular"},
306 |                     {"balance": 200, "type": "bonus"}
307 |                 ]
308 |             }
309 |         }
310 |         mock_response.json.return_value = response_data
311 | 
312 |         # Mock the AsyncClient.get method
313 |         with patch("httpx.AsyncClient") as mock_client:
314 |             mock_client.return_value.__aenter__.return_value.get.return_value = mock_response
315 |             
316 |             # Call the method
317 |             result = await client.get_balance()
318 |             
319 |             # Verify the result
320 |             assert isinstance(result, Balance)
321 |             assert result.balances == response_data["balances"]
322 |             assert result.get_credit() == 500
323 | 
324 |     @pytest.mark.asyncio
325 |     async def test_get_balance_error_response(self, client):
326 |         """Test get_balance with error response."""
327 |         # Create error response
328 |         error_response = MagicMock()
329 |         error_response.status_code = 403
330 |         error_response.text = "Forbidden"
331 | 
332 |         # Mock the AsyncClient.get method
333 |         with patch("httpx.AsyncClient") as mock_client:
334 |             mock_client.return_value.__aenter__.return_value.get.return_value = error_response
335 |             
336 |             # Call the method and expect exception
337 |             with pytest.raises(NijiVoiceAPIError) as excinfo:
338 |                 await client.get_balance()
339 |             
340 |             # Verify the exception
341 |             assert excinfo.value.status_code == 403
342 |             assert "クレジット残高の取得に失敗しました" in str(excinfo.value)
```