# 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)
```