#
tokens: 8741/50000 12/12 files
lines: off (toggle) GitHub
raw markdown copy
# 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:
--------------------------------------------------------------------------------

```
# Python-generated files
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg

# Virtual environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# Testing
.coverage
.pytest_cache/
htmlcov/
.tox/
coverage.xml
*.cover
.hypothesis/

# VSCode 
.vscode/
*.code-workspace

# Editor specific files
*~
.*.swp
.DS_Store

# Local environment files
.env
.env:Zone.Identifier
.python-version

**/.claude/settings.local.json

```

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

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

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

```
[pytest]
asyncio_mode = strict
asyncio_default_fixture_loop_scope = function
```

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

```python
def main():
    print("Hello from nijivoice-mcp-beta!")


if __name__ == "__main__":
    main()

```

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

```python
"""Configuration for pytest."""
import pytest

# No need to redefine event_loop fixture
# pytest-asyncio already provides this functionality
```

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

```python
from .models import VoiceActor, VoiceGenerationRequest, Balance, RecommendedParameters
from .api import NijiVoiceClient
from .exceptions import NijiVoiceError, NijiVoiceAPIError

__version__ = "0.1.0"
```

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

```toml
[project]
name = "nijivoice-mcp-beta"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "fastmcp>=2.2.8",
    "httpx>=0.28.1",
    "logger>=1.4",
    "pytest>=8.3.5",
    "pytest-asyncio>=0.26.0",
    "pytest-cov>=4.1.0",
    "python-dotenv>=1.1.0",
]

```

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

```python
"""Test suite for exceptions.py."""
import pytest
from nijivoice.exceptions import NijiVoiceError, NijiVoiceAPIError


class TestNijiVoiceExceptions:
    """Test cases for NijiVoice exceptions."""

    def test_nijivoice_error(self):
        """Test NijiVoiceError base exception."""
        # Create an instance
        error = NijiVoiceError("Test error message")
        
        # Check if it's an instance of Exception
        assert isinstance(error, Exception)
        
        # Check the error message
        assert str(error) == "Test error message"

    def test_nijivoice_api_error_with_status_code(self):
        """Test NijiVoiceAPIError with status code."""
        # Create an instance with status code
        error = NijiVoiceAPIError("API error message", status_code=404)
        
        # Check inheritance
        assert isinstance(error, NijiVoiceError)
        
        # Check properties
        assert str(error) == "API error message"
        assert error.status_code == 404

    def test_nijivoice_api_error_without_status_code(self):
        """Test NijiVoiceAPIError without status code."""
        # Create an instance without status code
        error = NijiVoiceAPIError("API error message")
        
        # Check properties
        assert str(error) == "API error message"
        assert error.status_code is None
```

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

```python
"""Mock implementation of the NijiVoiceClient for testing."""
from typing import List, Optional, Dict, Any
from nijivoice.models import VoiceActor, VoiceGenerationRequest, Balance
from nijivoice.exceptions import NijiVoiceAPIError

class MockNijiVoiceClient:
    """Mock implementation of NijiVoiceClient for testing."""
    
    def __init__(self, api_key: Optional[str] = None, timeout: Optional[float] = 30.0):
        """Mock initialization."""
        self.api_key = api_key
        self.timeout = timeout
        self.should_fail = False
        self.headers = {
            "x-api-key": api_key or "",
            "Accept": "application/json",
        }
        self.voice_actors = [
            VoiceActor(
                id="voice-actor-1",
                name="Test Actor 1",
                description="Test description",
                gender="Male",
                age=25,
            ),
            VoiceActor(
                id="voice-actor-2",
                name="Test Actor 2",
                description="Another test description",
                gender="Female",
                age=30,
            ),
        ]
    
    def set_should_fail(self, should_fail: bool):
        """Set whether API calls should fail."""
        self.should_fail = should_fail
    
    async def get_voice_actors(self) -> List[VoiceActor]:
        """Mock implementation of get_voice_actors."""
        if self.should_fail:
            raise NijiVoiceAPIError("Failed to get voice actors", status_code=500)
        return self.voice_actors
    
    async def generate_voice(self, request: VoiceGenerationRequest) -> Dict[str, Any]:
        """Mock implementation of generate_voice."""
        if self.should_fail:
            raise NijiVoiceAPIError("Failed to generate voice", status_code=500)
        return {
            "encoded_voice": "base64_encoded_mock_audio_data",
            "remaining_credits": 100,
            "format": request.format,
            "actor_id": request.id
        }
    
    async def get_balance(self) -> Balance:
        """Mock implementation of get_balance."""
        if self.should_fail:
            raise NijiVoiceAPIError("Failed to get balance", status_code=500)
        return Balance(balance=100)
```

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

```python
"""Test suite for server.py."""
import os
import pytest
from unittest.mock import patch, MagicMock, AsyncMock
import asyncio
from fastmcp import FastMCP

from nijivoice.models import VoiceActor, VoiceGenerationRequest, Balance
from nijivoice.exceptions import NijiVoiceAPIError
from tests.mock_client import MockNijiVoiceClient

# Patch environment to avoid loading actual API keys
@pytest.fixture(autouse=True)
def mock_env():
    """Mock environment variables."""
    with patch.dict(os.environ, {"NIJIVOICE_API_KEY": "test-api-key"}):
        yield

@pytest.fixture
def mock_client():
    """Create a mock NijiVoiceClient."""
    client = MockNijiVoiceClient(api_key="test-api-key")
    return client

@pytest.fixture
def server_module():
    """Import the server module with patched client."""
    with patch("nijivoice.api.NijiVoiceClient", MockNijiVoiceClient):
        import server
        yield server

@pytest.mark.asyncio
async def test_get_voice_actors(server_module, mock_client):
    """Test get_voice_actors function returns list of voice actors."""
    # Patch the client in server module
    server_module.client = mock_client
    
    # Get the tool function
    get_voice_actors = server_module.get_voice_actors
    
    # Call the function
    result = await get_voice_actors()
    
    # Check if result is a list of VoiceActor objects
    assert isinstance(result, list)
    assert all(isinstance(actor, VoiceActor) for actor in result)
    assert len(result) == 2
    assert result[0].id == "voice-actor-1"
    assert result[1].id == "voice-actor-2"

@pytest.mark.asyncio
async def test_get_voice_actors_error(server_module, mock_client):
    """Test error handling in get_voice_actors function."""
    # Set client to fail
    mock_client.set_should_fail(True)
    
    # Patch the client in server module
    server_module.client = mock_client
    
    # Get the tool function
    get_voice_actors = server_module.get_voice_actors
    
    # Call the function and expect an exception
    with pytest.raises(NijiVoiceAPIError) as excinfo:
        await get_voice_actors()
    
    # Check the exception details
    assert excinfo.value.status_code == 500
    assert "Failed to get voice actors" in str(excinfo.value)

@pytest.mark.asyncio
async def test_mcp_tools_registration(server_module):
    """Test that the tool is properly registered with FastMCP."""
    mcp = server_module.mcp
    
    # Check if FastMCP instance is created
    assert isinstance(mcp, FastMCP)
    assert mcp.name == "nijivoice MCP"
    
    # Check if the get_voice_actors function exists and is decorated
    assert hasattr(server_module, "get_voice_actors")
    get_voice_actors = server_module.get_voice_actors
    
    # Check if function has the expected docstring
    assert "Get the list of voice actors." in get_voice_actors.__doc__

@pytest.mark.asyncio
async def test_client_initialization(server_module):
    """Test that the NijiVoiceClient is initialized properly."""
    # Check that the client is initialized
    client = server_module.client
    assert isinstance(client, MockNijiVoiceClient)
    assert client.api_key == "test-api-key"

@pytest.mark.asyncio
async def test_dotenv_loading():
    """Test that dotenv is loaded properly."""
    # Create a mock for load_dotenv
    with patch("dotenv.load_dotenv") as mock_load_dotenv:
        # Import the server module to trigger load_dotenv
        with patch("nijivoice.api.NijiVoiceClient"):
            import importlib
            import sys
            
            # Remove the module from sys.modules if it exists
            if "server" in sys.modules:
                del sys.modules["server"]
                
            # Re-import the module
            import server
            
            # Check that load_dotenv was called
            mock_load_dotenv.assert_called_once()

@pytest.mark.asyncio
async def test_generate_voice(server_module, mock_client):
    """Test generate_voice function."""
    # Setup
    server_module.client = mock_client
    
    # Create a detailed mock for client.generate_voice
    mock_response = {"encoded_voice": "mock_data", "credits": 100}
    mock_client.generate_voice = AsyncMock(return_value=mock_response)
    
    # Replace get_voice_actors with a mock
    mock_actors = [
        VoiceActor(id="test-actor-1", name="Test Actor 1"),
        VoiceActor(id="test-actor-2", name="Test Actor 2")
    ]
    server_module.get_voice_actors = AsyncMock(return_value=mock_actors)
    
    # Call the function
    result = await server_module.generate_voice(script="こんにちは")
    
    # Verify
    server_module.get_voice_actors.assert_called_once()
    
    # Check that the client's generate_voice was called with the correct request
    called_request = mock_client.generate_voice.call_args[1]["request"]
    assert isinstance(called_request, VoiceGenerationRequest)
    assert called_request.script == "こんにちは"
    
    # Assert the result matches the mock response
    assert result == mock_response

@pytest.mark.asyncio
async def test_generate_voice_no_actors(server_module, mock_client):
    """Test generate_voice function when no actors are available."""
    # Setup
    server_module.client = mock_client
    
    # Replace get_voice_actors with a mock that returns empty list
    server_module.get_voice_actors = AsyncMock(return_value=[])
    
    # Call the function
    result = await server_module.generate_voice(script="こんにちは")
    
    # Verify
    server_module.get_voice_actors.assert_called_once()
    
    # The result should indicate an error
    assert result["status"] == "error"
    assert "利用可能なVoice Actorが見つかりません" in result["message"]

@pytest.mark.asyncio
async def test_get_credit_balance(server_module, mock_client):
    """Test get_credit_balance function."""
    # Setup
    server_module.client = mock_client
    mock_balance = Balance(balance=200)
    mock_client.get_balance = AsyncMock(return_value=mock_balance)
    
    # Call the function
    result = await server_module.get_credit_balance()
    
    # Verify
    mock_client.get_balance.assert_called_once()
    assert result == 200

@pytest.mark.asyncio
async def test_voice_actors_resource(server_module, mock_client):
    """Test voice_actors_resource function."""
    # Setup
    server_module.client = mock_client
    
    # Replace get_voice_actors with a mock
    mock_actors = [
        VoiceActor(id="test-actor-1", name="Test Actor 1"),
        VoiceActor(id="test-actor-2", name="Test Actor 2")
    ]
    server_module.get_voice_actors = AsyncMock(return_value=mock_actors)
    
    # Call the function
    result = await server_module.voice_actors_resource()
    
    # Verify
    server_module.get_voice_actors.assert_called_once()
    assert result == mock_actors

@pytest.mark.asyncio
async def test_voice_actor_resource(server_module, mock_client):
    """Test voice_actor_resource function."""
    # Setup
    server_module.client = mock_client
    
    # Replace get_voice_actors with a mock
    mock_actors = [
        VoiceActor(id="test-actor-1", name="Test Actor 1"),
        VoiceActor(id="test-actor-2", name="Test Actor 2")
    ]
    server_module.get_voice_actors = AsyncMock(return_value=mock_actors)
    
    # Call the function with existing actor ID
    result = await server_module.voice_actor_resource(actor_id="test-actor-1")
    
    # Verify
    server_module.get_voice_actors.assert_called_once()
    assert result == mock_actors[0]
    
    # Reset the mock
    server_module.get_voice_actors.reset_mock()
    
    # Call the function with non-existing actor ID
    result = await server_module.voice_actor_resource(actor_id="non-existing")
    
    # Verify
    server_module.get_voice_actors.assert_called_once()
    assert result is None

@pytest.mark.asyncio
async def test_credit_balance_resource(server_module, mock_client):
    """Test credit_balance_resource function."""
    # Setup
    server_module.client = mock_client
    mock_balance = Balance(balance=200)
    mock_client.get_balance = AsyncMock(return_value=mock_balance)
    
    # Call the function
    result = await server_module.credit_balance_resource()
    
    # Verify
    mock_client.get_balance.assert_called_once()
    assert result == mock_balance

@pytest.mark.asyncio
async def test_voice_generation_prompt(server_module):
    """Test voice_generation_prompt function."""
    # Call the function
    result = server_module.voice_generation_prompt()
    
    # Verify
    assert isinstance(result, str)
    assert "にじボイス音声生成" in result
    assert "利用可能なVoice Actor" in result
```

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

```python
"""Test suite for models.py."""
import pytest
from pydantic import ValidationError

from nijivoice.models import (
    RecommendedParameters,
    VoiceStyle,
    VoiceActor,
    VoiceGenerationRequest,
    Balance
)


class TestRecommendedParameters:
    """Tests for RecommendedParameters model."""

    def test_create_recommended_parameters(self):
        """Test creating RecommendedParameters with default values."""
        params = RecommendedParameters()
        assert params.emotional_level == 1.0
        assert params.sound_duration == 1.0

    def test_create_with_values(self):
        """Test creating RecommendedParameters with values."""
        params = RecommendedParameters(
            emotional_level=0.8,
            sound_duration=1.2
        )
        assert params.emotional_level == 0.8
        assert params.sound_duration == 1.2

    def test_create_with_aliases(self):
        """Test creating RecommendedParameters using alias names."""
        params = RecommendedParameters(
            emotionalLevel=0.8,
            soundDuration=1.2
        )
        assert params.emotional_level == 0.8
        assert params.sound_duration == 1.2

    def test_ignore_extra_fields(self):
        """Test that extra fields are ignored."""
        params = RecommendedParameters(
            emotional_level=0.8,
            sound_duration=1.2,
            extra_field="should be ignored"
        )
        assert not hasattr(params, "extra_field")


class TestVoiceStyle:
    """Tests for VoiceStyle model."""

    def test_create_voice_style(self):
        """Test creating VoiceStyle."""
        style = VoiceStyle(id=1, style="normal")
        assert style.id == 1
        assert style.style == "normal"

    def test_voice_style_validation(self):
        """Test VoiceStyle validation."""
        # Missing required field should raise ValidationError
        with pytest.raises(ValidationError):
            VoiceStyle(style="normal")  # Missing id
        
        with pytest.raises(ValidationError):
            VoiceStyle(id=1)  # Missing style


class TestVoiceActor:
    """Tests for VoiceActor model."""

    def test_create_voice_actor_minimal(self):
        """Test creating VoiceActor with minimal required fields."""
        actor = VoiceActor(id="actor-1", name="Test Actor")
        assert actor.id == "actor-1"
        assert actor.name == "Test Actor"
        assert actor.description == ""  # Default value
        assert actor.gender is None
        assert actor.age is None

    def test_create_voice_actor_full(self):
        """Test creating VoiceActor with all fields."""
        actor = VoiceActor(
            id="actor-1",
            name="Test Actor",
            name_reading="テストアクター",
            age=25,
            gender="Female",
            birth_month=7,
            birth_day=15,
            description="Test description",
            small_image_url="https://example.com/small.jpg",
            medium_image_url="https://example.com/medium.jpg",
            large_image_url="https://example.com/large.jpg",
            sample_voice_url="https://example.com/sample.mp3",
            sample_script="こんにちは",
            recommended_voice_speed=1.2,
            recommended_emotional_level=0.8,
            recommended_sound_duration=1.0,
            recommended_parameters=RecommendedParameters(
                emotional_level=0.8,
                sound_duration=1.0
            ),
            voice_styles=[
                VoiceStyle(id=1, style="normal"),
                VoiceStyle(id=2, style="happy")
            ]
        )
        
        assert actor.id == "actor-1"
        assert actor.name == "Test Actor"
        assert actor.name_reading == "テストアクター"
        assert actor.age == 25
        assert actor.gender == "Female"
        assert actor.birth_month == 7
        assert actor.birth_day == 15
        assert actor.description == "Test description"
        assert actor.small_image_url == "https://example.com/small.jpg"
        assert actor.medium_image_url == "https://example.com/medium.jpg"
        assert actor.large_image_url == "https://example.com/large.jpg"
        assert actor.sample_voice_url == "https://example.com/sample.mp3"
        assert actor.sample_script == "こんにちは"
        assert actor.recommended_voice_speed == 1.2
        assert actor.recommended_emotional_level == 0.8
        assert actor.recommended_sound_duration == 1.0
        assert isinstance(actor.recommended_parameters, RecommendedParameters)
        assert len(actor.voice_styles) == 2
        assert actor.voice_styles[0].id == 1
        assert actor.voice_styles[0].style == "normal"

    def test_create_with_aliases(self):
        """Test creating VoiceActor using alias names."""
        actor = VoiceActor(
            id="actor-1",
            name="Test Actor",
            nameReading="テストアクター",
            birthMonth=7,
            birthDay=15,
            smallImageUrl="https://example.com/small.jpg",
            mediumImageUrl="https://example.com/medium.jpg",
            largeImageUrl="https://example.com/large.jpg",
            sampleVoiceUrl="https://example.com/sample.mp3",
            sampleScript="こんにちは",
            recommendedVoiceSpeed=1.2,
            recommendedEmotionalLevel=0.8,
            recommendedSoundDuration=1.0,
            recommendedParameters={
                "emotionalLevel": 0.8,
                "soundDuration": 1.0
            },
            voiceStyles=[
                {"id": 1, "style": "normal"},
                {"id": 2, "style": "happy"}
            ]
        )
        
        assert actor.name_reading == "テストアクター"
        assert actor.birth_month == 7
        assert actor.birth_day == 15
        assert actor.small_image_url == "https://example.com/small.jpg"
        assert isinstance(actor.recommended_parameters, RecommendedParameters)
        assert len(actor.voice_styles) == 2


class TestVoiceGenerationRequest:
    """Tests for VoiceGenerationRequest model."""

    def test_create_minimal_request(self):
        """Test creating VoiceGenerationRequest with minimal fields."""
        request = VoiceGenerationRequest(
            id="actor-1",
            script="こんにちは"
        )
        
        assert request.id == "actor-1"
        assert request.script == "こんにちは"
        assert request.speed == 1.0  # Default value
        assert request.emotional_level is None
        assert request.sound_duration is None
        assert request.format == "mp3"  # Default value

    def test_create_full_request(self):
        """Test creating VoiceGenerationRequest with all fields."""
        request = VoiceGenerationRequest(
            id="actor-1",
            script="こんにちは",
            speed=1.5,
            emotional_level=0.8,
            sound_duration=1.2,
            format="wav"
        )
        
        assert request.id == "actor-1"
        assert request.script == "こんにちは"
        assert request.speed == 1.5
        assert request.emotional_level == 0.8
        assert request.sound_duration == 1.2
        assert request.format == "wav"

    def test_create_request_with_aliases(self):
        """Test creating VoiceGenerationRequest with aliases."""
        request = VoiceGenerationRequest(
            id="actor-1",
            script="こんにちは",
            speed=1.5,
            emotionalLevel=0.8,
            soundDuration=1.2
        )
        
        assert request.emotional_level == 0.8
        assert request.sound_duration == 1.2

    def test_speed_validation(self):
        """Test speed validation."""
        # Valid values
        request = VoiceGenerationRequest(id="actor-1", script="こんにちは", speed=0.4)
        assert request.speed == 0.4
        
        request = VoiceGenerationRequest(id="actor-1", script="こんにちは", speed=3.0)
        assert request.speed == 3.0
        
        # Invalid values
        with pytest.raises(ValidationError):
            VoiceGenerationRequest(id="actor-1", script="こんにちは", speed=0.3)  # Too low
        
        with pytest.raises(ValidationError):
            VoiceGenerationRequest(id="actor-1", script="こんにちは", speed=3.1)  # Too high

    def test_emotional_level_validation(self):
        """Test emotional_level validation."""
        # Valid values
        request = VoiceGenerationRequest(id="actor-1", script="こんにちは", emotional_level=0.0)
        assert request.emotional_level == 0.0
        
        request = VoiceGenerationRequest(id="actor-1", script="こんにちは", emotional_level=1.5)
        assert request.emotional_level == 1.5
        
        # Invalid values
        with pytest.raises(ValidationError):
            VoiceGenerationRequest(id="actor-1", script="こんにちは", emotional_level=-0.1)  # Too low
        
        with pytest.raises(ValidationError):
            VoiceGenerationRequest(id="actor-1", script="こんにちは", emotional_level=1.6)  # Too high

    def test_format_validation(self):
        """Test format validation."""
        # Valid values
        request = VoiceGenerationRequest(id="actor-1", script="こんにちは", format="mp3")
        assert request.format == "mp3"
        
        request = VoiceGenerationRequest(id="actor-1", script="こんにちは", format="wav")
        assert request.format == "wav"
        
        # Invalid values
        with pytest.raises(ValidationError):
            VoiceGenerationRequest(id="actor-1", script="こんにちは", format="flac")

    def test_serializers(self):
        """Test field serializers."""
        request = VoiceGenerationRequest(
            id="actor-1",
            script="こんにちは",
            speed=1.5,
            emotional_level=0.8,
            sound_duration=1.2,
            format="wav"  # Already lowercase since validation happens before serialization
        )
        
        data = request.model_dump(by_alias=True)
        
        # Check serialized values
        assert data["speed"] == "1.5"  # Serialized as string
        assert data["emotionalLevel"] == "0.8"  # Serialized as string
        assert data["soundDuration"] == "1.2"  # Serialized as string
        assert data["format"] == "wav"  # Lowercase conversion


class TestBalance:
    """Tests for Balance model."""

    def test_create_simple_balance(self):
        """Test creating Balance with simple structure."""
        balance = Balance(balance=500)
        assert balance.balance == 500
        assert balance.balances is None
        assert balance.get_credit() == 500

    def test_create_complex_balance(self):
        """Test creating Balance with complex structure."""
        balance = Balance(balances={
            "remainingBalance": 800,
            "credits": [
                {"balance": 500, "type": "regular"},
                {"balance": 300, "type": "bonus"}
            ]
        })
        
        assert balance.balance is None
        assert isinstance(balance.balances, dict)
        assert balance.get_credit() == 800  # Should get remainingBalance

    def test_get_credit_with_balance_field(self):
        """Test get_credit with balance field."""
        balance = Balance(balance=500)
        assert balance.get_credit() == 500

    def test_get_credit_with_remaining_balance(self):
        """Test get_credit with remainingBalance in balances."""
        balance = Balance(balances={"remainingBalance": 800})
        assert balance.get_credit() == 800

    def test_get_credit_with_balance_in_balances(self):
        """Test get_credit with balance in balances."""
        balance = Balance(balances={"balance": 700})
        assert balance.get_credit() == 700

    def test_get_credit_with_credits_list(self):
        """Test get_credit with credits list in balances."""
        balance = Balance(balances={
            "credits": [
                {"balance": 500, "type": "regular"},
                {"balance": 300, "type": "bonus"}
            ]
        })
        assert balance.get_credit() == 500  # Should get first credit balance

    def test_get_credit_with_no_balance_info(self):
        """Test get_credit with no balance information."""
        balance = Balance(balances={"other": "value"})
        assert balance.get_credit() == 0  # Should return 0 if no balance info

    def test_get_credit_with_empty_balance(self):
        """Test get_credit with empty Balance."""
        balance = Balance()
        assert balance.get_credit() == 0
```

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

```python
"""Test suite for NijiVoiceClient in api.py."""
import os
import pytest
import json
from unittest.mock import patch, AsyncMock, MagicMock

import httpx
import pytest_asyncio

from nijivoice.api import NijiVoiceClient
from nijivoice.models import VoiceActor, VoiceGenerationRequest, Balance
from nijivoice.exceptions import NijiVoiceAPIError


@pytest.fixture
def mock_env():
    """Mock environment variables."""
    with patch.dict(os.environ, {"NIJIVOICE_API_KEY": "test-api-key"}):
        yield


@pytest.fixture
def mock_response():
    """Create a mock response."""
    mock = MagicMock()
    mock.status_code = 200
    mock.text = "Success"
    return mock


@pytest.fixture
def client():
    """Create a NijiVoiceClient instance."""
    return NijiVoiceClient(api_key="test-api-key")


class TestNijiVoiceClient:
    """Test cases for NijiVoiceClient."""

    def test_init_with_api_key(self):
        """Test initialization with api_key parameter."""
        client = NijiVoiceClient(api_key="test-api-key")
        assert client.api_key == "test-api-key"
        assert client.timeout == 30.0
        assert client.headers == {
            "x-api-key": "test-api-key",
            "Accept": "application/json",
        }

    def test_init_without_api_key(self, mock_env):
        """Test initialization without api_key parameter."""
        client = NijiVoiceClient()
        assert client.api_key == "test-api-key"
        assert client.timeout == 30.0

    def test_init_with_custom_timeout(self):
        """Test initialization with custom timeout."""
        client = NijiVoiceClient(api_key="test-api-key", timeout=60.0)
        assert client.timeout == 60.0

    def test_init_without_api_key_and_env(self):
        """Test initialization without api_key parameter and environment variable."""
        with patch.dict(os.environ, {}, clear=True):
            with pytest.raises(ValueError) as excinfo:
                NijiVoiceClient()
            assert "APIキーが指定されていません" in str(excinfo.value)

    @pytest.mark.asyncio
    async def test_get_voice_actors_success(self, client, mock_response):
        """Test get_voice_actors successful response."""
        # Create mock data
        voice_actors_data = [
            {
                "id": "actor-id-1",
                "name": "Actor 1",
                "gender": "Female",
                "age": 25
            },
            {
                "id": "actor-id-2",
                "name": "Actor 2",
                "gender": "Male",
                "age": 30
            }
        ]
        mock_response.json.return_value = voice_actors_data

        # Mock the AsyncClient.get method
        with patch("httpx.AsyncClient") as mock_client:
            mock_client.return_value.__aenter__.return_value.get.return_value = mock_response
            
            # Call the method
            result = await client.get_voice_actors()
            
            # Verify the result
            assert len(result) == 2
            assert isinstance(result[0], VoiceActor)
            assert result[0].id == "actor-id-1"
            assert result[0].name == "Actor 1"
            assert result[1].id == "actor-id-2"
            assert result[1].name == "Actor 2"
            
            # Verify the request was made correctly
            mock_client.return_value.__aenter__.return_value.get.assert_called_once_with(
                f"{client.BASE_URL}/voice-actors", 
                headers=client.headers
            )

    @pytest.mark.asyncio
    async def test_get_voice_actors_voiceActors_key(self, client, mock_response):
        """Test get_voice_actors with voiceActors key in response."""
        # Create mock data with voiceActors key
        voice_actors_data = {
            "voiceActors": [
                {
                    "id": "actor-id-1",
                    "name": "Actor 1",
                    "gender": "Female",
                    "age": 25
                },
                {
                    "id": "actor-id-2",
                    "name": "Actor 2",
                    "gender": "Male",
                    "age": 30
                }
            ]
        }
        mock_response.json.return_value = voice_actors_data

        # Mock the AsyncClient.get method
        with patch("httpx.AsyncClient") as mock_client:
            mock_client.return_value.__aenter__.return_value.get.return_value = mock_response
            
            # Call the method
            result = await client.get_voice_actors()
            
            # Verify the result
            assert len(result) == 2
            assert isinstance(result[0], VoiceActor)
            assert result[0].id == "actor-id-1"
            assert result[0].name == "Actor 1"

    @pytest.mark.asyncio
    async def test_get_voice_actors_unexpected_format(self, client, mock_response):
        """Test get_voice_actors with unexpected data format."""
        # Create unexpected data format
        mock_response.json.return_value = {"unexpectedKey": "unexpected value"}

        # Mock the AsyncClient.get method
        with patch("httpx.AsyncClient") as mock_client:
            mock_client.return_value.__aenter__.return_value.get.return_value = mock_response
            
            # Call the method
            result = await client.get_voice_actors()
            
            # Verify the result is an empty list
            assert isinstance(result, list)
            assert len(result) == 0

    @pytest.mark.asyncio
    async def test_get_voice_actors_error_response(self, client):
        """Test get_voice_actors with error response."""
        # Create error response
        error_response = MagicMock()
        error_response.status_code = 404
        error_response.text = "Not Found"

        # Mock the AsyncClient.get method
        with patch("httpx.AsyncClient") as mock_client:
            mock_client.return_value.__aenter__.return_value.get.return_value = error_response
            
            # Call the method and expect exception
            with pytest.raises(NijiVoiceAPIError) as excinfo:
                await client.get_voice_actors()
            
            # Verify the exception
            assert excinfo.value.status_code == 404
            assert "Voice Actor一覧の取得に失敗しました" in str(excinfo.value)

    @pytest.mark.asyncio
    async def test_get_voice_actors_timeout(self, client):
        """Test get_voice_actors with timeout."""
        # Mock the AsyncClient.get method to raise TimeoutError
        with patch("httpx.AsyncClient") as mock_client:
            mock_client.return_value.__aenter__.return_value.get.side_effect = TimeoutError("Timeout")
            
            # Call the method and expect exception
            with pytest.raises(NijiVoiceAPIError) as excinfo:
                await client.get_voice_actors()
            
            # Verify the exception
            assert excinfo.value.status_code == 408
            assert "APIリクエストがタイムアウトしました" in str(excinfo.value)

    @pytest.mark.asyncio
    async def test_get_voice_actors_general_exception(self, client):
        """Test get_voice_actors with general exception."""
        # Mock the AsyncClient.get method to raise Exception
        with patch("httpx.AsyncClient") as mock_client:
            mock_client.return_value.__aenter__.return_value.get.side_effect = Exception("Some error")
            
            # Call the method and expect exception
            with pytest.raises(NijiVoiceAPIError) as excinfo:
                await client.get_voice_actors()
            
            # Verify the exception
            assert excinfo.value.status_code == 500
            assert "API呼び出し中にエラーが発生しました" in str(excinfo.value)

    @pytest.mark.asyncio
    async def test_generate_voice_success(self, client, mock_response):
        """Test generate_voice successful response."""
        # Create mock response data
        response_data = {
            "encoded_voice": "base64_encoded_data",
            "remaining_credits": 100
        }
        mock_response.json.return_value = response_data

        # Create request
        request = VoiceGenerationRequest(
            id="actor-id-1",
            script="これはテストです",
            speed=1.0
        )

        # Mock the AsyncClient.post method
        with patch("httpx.AsyncClient") as mock_client:
            mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
            
            # Call the method
            result = await client.generate_voice(request=request)
            
            # Verify the result
            assert result == response_data
            
            # Verify the request was made correctly
            mock_client.return_value.__aenter__.return_value.post.assert_called_once_with(
                f"{client.BASE_URL}/voice-actors/{request.id}/generate-voice",
                headers=client.headers,
                json=request.model_dump(by_alias=True)
            )

    @pytest.mark.asyncio
    async def test_generate_voice_error_response(self, client):
        """Test generate_voice with error response."""
        # Create error response
        error_response = MagicMock()
        error_response.status_code = 500
        error_response.text = "Server Error"

        # Create request
        request = VoiceGenerationRequest(
            id="actor-id-1",
            script="これはテストです",
            speed=1.0
        )

        # Mock the AsyncClient.post method
        with patch("httpx.AsyncClient") as mock_client:
            mock_client.return_value.__aenter__.return_value.post.return_value = error_response
            
            # Call the method and expect exception
            with pytest.raises(NijiVoiceAPIError) as excinfo:
                await client.generate_voice(request=request)
            
            # Verify the exception
            assert excinfo.value.status_code == 500
            assert "音声生成に失敗しました" in str(excinfo.value)

    @pytest.mark.asyncio
    async def test_get_balance_success(self, client, mock_response):
        """Test get_balance successful response."""
        # Create mock response data
        response_data = {"balance": 500}
        mock_response.json.return_value = response_data

        # Mock the AsyncClient.get method
        with patch("httpx.AsyncClient") as mock_client:
            mock_client.return_value.__aenter__.return_value.get.return_value = mock_response
            
            # Call the method
            result = await client.get_balance()
            
            # Verify the result
            assert isinstance(result, Balance)
            assert result.balance == 500
            assert result.get_credit() == 500
            
            # Verify the request was made correctly
            mock_client.return_value.__aenter__.return_value.get.assert_called_once_with(
                f"{client.BASE_URL}/balances",
                headers=client.headers
            )

    @pytest.mark.asyncio
    async def test_get_balance_complex_response(self, client, mock_response):
        """Test get_balance with complex response structure."""
        # Create mock response data with complex structure
        response_data = {
            "balances": {
                "remainingBalance": 500,
                "credits": [
                    {"balance": 300, "type": "regular"},
                    {"balance": 200, "type": "bonus"}
                ]
            }
        }
        mock_response.json.return_value = response_data

        # Mock the AsyncClient.get method
        with patch("httpx.AsyncClient") as mock_client:
            mock_client.return_value.__aenter__.return_value.get.return_value = mock_response
            
            # Call the method
            result = await client.get_balance()
            
            # Verify the result
            assert isinstance(result, Balance)
            assert result.balances == response_data["balances"]
            assert result.get_credit() == 500

    @pytest.mark.asyncio
    async def test_get_balance_error_response(self, client):
        """Test get_balance with error response."""
        # Create error response
        error_response = MagicMock()
        error_response.status_code = 403
        error_response.text = "Forbidden"

        # Mock the AsyncClient.get method
        with patch("httpx.AsyncClient") as mock_client:
            mock_client.return_value.__aenter__.return_value.get.return_value = error_response
            
            # Call the method and expect exception
            with pytest.raises(NijiVoiceAPIError) as excinfo:
                await client.get_balance()
            
            # Verify the exception
            assert excinfo.value.status_code == 403
            assert "クレジット残高の取得に失敗しました" in str(excinfo.value)
```