Commit e33313c7 authored by Jan Reimes's avatar Jan Reimes
Browse files

test(tests): add comprehensive test suite for CLI and configuration

* Add tests for CLI app initialization and version retrieval.
* Implement integration tests for document conversion providers.
* Create configuration validation tests for provider settings.
* Add logging configuration tests to ensure proper logging levels.
parent f55aadd0
Loading
Loading
Loading
Loading

tests/test_cli_app.py

0 → 100644
+47 −0
Original line number Diff line number Diff line
"""Tests for CLI app initialization."""

from __future__ import annotations

from unittest.mock import patch

from typer.testing import CliRunner

from pdf_remote_converter.cli.app import _get_version, app

runner = CliRunner()


def test_cli_app_version() -> None:
    """Test --version flag shows version."""
    result = runner.invoke(app, ["--version"])

    assert result.exit_code == 0
    # Version should be displayed (could be 0.0.0 in dev mode)
    assert "0.0.0" in result.stdout or "version" in result.stdout.lower()


def test_get_version_from_metadata() -> None:
    """Test _get_version retrieves version from package metadata."""
    version = _get_version()

    # Should return either a valid version or fallback
    assert isinstance(version, str)
    assert len(version) > 0


def test_get_version_fallback() -> None:
    """Test _get_version falls back to 0.0.0 when metadata unavailable."""
    # When package is not installed, it should return fallback
    with patch("pdf_remote_converter.cli.app.version", side_effect=Exception("Not found")):
        version = _get_version()
        assert version == "0.0.0"


def test_cli_app_no_args_shows_help() -> None:
    """Test running app with no arguments shows help."""
    result = runner.invoke(app, [])

    # Exit code 0 for help, or can be non-zero based on no_args_is_help
    assert result.exit_code in (0, 2)
    output = result.stdout or result.stderr or ""
    assert "convert" in output.lower() or "help" in output.lower()
+290 −0
Original line number Diff line number Diff line
"""Tests for PDF Remote Converter CLI integration."""

from __future__ import annotations

from pathlib import Path
from typing import Never

from conftest import MockProvider
from typer.testing import CliRunner

from pdf_remote_converter.cli.app import app
from pdf_remote_converter.providers.models import ConversionResult

runner = CliRunner()


def test_cli_convert_success(tmp_path: Path, monkeypatch) -> None:
    """Test successful CLI conversion with configured provider."""
    input_file = tmp_path / "sample.docx"
    input_file.write_text("test content", encoding="utf-8")
    output_file = tmp_path / "output.pdf"

    def mock_get_configured(settings):
        return [MockProvider()]

    def mock_convert_with_failover(input_path, output_path, providers, force=False):
        output_path.write_bytes(b"%PDF-1.4")
        return ConversionResult(
            output_path=output_path,
            provider="mock",
            from_cache=False,
            credits_used=1,
        )

    monkeypatch.setenv("CLOUDCONVERT_API_KEY", "test-key")
    monkeypatch.setattr(
        "pdf_remote_converter.cli.commands.get_configured_providers",
        mock_get_configured,
    )
    monkeypatch.setattr(
        "pdf_remote_converter.cli.commands.convert_with_failover",
        mock_convert_with_failover,
    )

    result = runner.invoke(app, ["convert", str(input_file), str(output_file)])

    assert result.exit_code == 0
    assert "Converted to" in result.stdout
    assert "mock" in result.stdout
    assert output_file.exists()


def test_cli_no_provider_configured(tmp_path: Path, monkeypatch) -> None:
    """Test CLI error when no providers are configured."""
    input_file = tmp_path / "sample.docx"
    input_file.write_text("test", encoding="utf-8")
    output_file = tmp_path / "output.pdf"

    # Clear all provider env vars
    monkeypatch.delenv("CLOUDCONVERT_API_KEY", raising=False)
    monkeypatch.delenv("ADOBE_CLIENT_ID", raising=False)
    monkeypatch.delenv("ZAMZAR_API_KEY", raising=False)
    monkeypatch.delenv("PDF_REMOTE_CONVERTER_API_KEY", raising=False)

    result = runner.invoke(app, ["convert", str(input_file), str(output_file)])

    assert result.exit_code == 1
    # Error output goes to stderr
    output = result.stdout + result.stderr
    assert "Error" in output


def test_cli_provider_auth_error(tmp_path: Path, monkeypatch) -> None:
    """Test CLI error handling for authentication failures."""
    input_file = tmp_path / "sample.docx"
    input_file.write_text("test", encoding="utf-8")
    output_file = tmp_path / "output.pdf"

    def mock_get_configured(settings):
        return [MockProvider()]

    def mock_convert_fail(input_path, output_path, providers, force=False) -> Never:
        from pdf_remote_converter.exceptions import AuthenticationError

        raise AuthenticationError("Mock auth failed")

    monkeypatch.setenv("CLOUDCONVERT_API_KEY", "bad-key")
    monkeypatch.setattr(
        "pdf_remote_converter.cli.commands.get_configured_providers",
        mock_get_configured,
    )
    monkeypatch.setattr(
        "pdf_remote_converter.cli.commands.convert_with_failover",
        mock_convert_fail,
    )

    result = runner.invoke(app, ["convert", str(input_file), str(output_file)])

    assert result.exit_code == 1
    # Error output goes to stderr
    output = result.stdout + result.stderr
    assert "Error" in output


def test_cli_cache_hit(tmp_path: Path, monkeypatch) -> None:
    """Test CLI reports cache hit when applicable."""
    input_file = tmp_path / "sample.docx"
    input_file.write_text("cached content", encoding="utf-8")
    output_file = tmp_path / "output.pdf"

    def mock_get_configured(settings):
        return [MockProvider()]

    def mock_convert_cached(input_path, output_path, providers, force=False):
        output_path.write_bytes(b"%PDF-cached")
        return ConversionResult(
            output_path=output_path,
            provider="mock",
            from_cache=True,  # Indicate this came from cache
            credits_used=0,
        )

    monkeypatch.setenv("CLOUDCONVERT_API_KEY", "test-key")
    monkeypatch.setattr(
        "pdf_remote_converter.cli.commands.get_configured_providers",
        mock_get_configured,
    )
    monkeypatch.setattr(
        "pdf_remote_converter.cli.commands.convert_with_failover",
        mock_convert_cached,
    )

    result = runner.invoke(app, ["convert", str(input_file), str(output_file)])

    assert result.exit_code == 0
    assert "from cache" in result.stdout


def test_cli_verbose_logging(tmp_path: Path, monkeypatch) -> None:
    """Test CLI --verbose flag enables debug logging."""
    input_file = tmp_path / "sample.docx"
    input_file.write_text("test", encoding="utf-8")
    output_file = tmp_path / "output.pdf"

    verbose_called = False

    def mock_setup_logging(verbose) -> None:
        nonlocal verbose_called
        verbose_called = verbose

    def mock_get_configured(settings):
        return [MockProvider()]

    def mock_convert_with_failover(input_path, output_path, providers, force=False):
        output_path.write_bytes(b"%PDF")
        return ConversionResult(
            output_path=output_path,
            provider="mock",
            from_cache=False,
            credits_used=1,
        )

    monkeypatch.setenv("CLOUDCONVERT_API_KEY", "test-key")
    monkeypatch.setattr(
        "pdf_remote_converter.cli.commands.setup_logging",
        mock_setup_logging,
    )
    monkeypatch.setattr(
        "pdf_remote_converter.cli.commands.get_configured_providers",
        mock_get_configured,
    )
    monkeypatch.setattr(
        "pdf_remote_converter.cli.commands.convert_with_failover",
        mock_convert_with_failover,
    )

    result = runner.invoke(
        app,
        ["convert", "--verbose", str(input_file), str(output_file)],
    )

    assert result.exit_code == 0
    assert verbose_called is True


def test_cli_force_flag(tmp_path: Path, monkeypatch) -> None:
    """Test CLI --force flag bypasses cache."""
    input_file = tmp_path / "sample.docx"
    input_file.write_text("test", encoding="utf-8")
    output_file = tmp_path / "output.pdf"

    force_flag_received = False

    def mock_get_configured(settings):
        return [MockProvider()]

    def mock_convert_with_failover(input_path, output_path, providers, force=False):
        nonlocal force_flag_received
        force_flag_received = force
        output_path.write_bytes(b"%PDF")
        return ConversionResult(
            output_path=output_path,
            provider="mock",
            from_cache=False,
            credits_used=1,
        )

    monkeypatch.setenv("CLOUDCONVERT_API_KEY", "test-key")
    monkeypatch.setattr(
        "pdf_remote_converter.cli.commands.get_configured_providers",
        mock_get_configured,
    )
    monkeypatch.setattr(
        "pdf_remote_converter.cli.commands.convert_with_failover",
        mock_convert_with_failover,
    )

    result = runner.invoke(
        app,
        ["convert", "--force", str(input_file), str(output_file)],
    )

    assert result.exit_code == 0
    assert force_flag_received is True


def test_cli_explicit_provider(tmp_path: Path, monkeypatch) -> None:
    """Test CLI --provider flag selects specific provider."""
    input_file = tmp_path / "sample.docx"
    input_file.write_text("test", encoding="utf-8")
    output_file = tmp_path / "output.pdf"

    selected_provider = None

    def mock_get_configured(settings):
        return [MockProvider(name="cloudconvert"), MockProvider(name="adobe")]

    def mock_select_provider(preferred, settings):
        nonlocal selected_provider
        selected_provider = preferred
        return MockProvider(name=preferred)

    def mock_convert_with_failover(input_path, output_path, providers, force=False):
        output_path.write_bytes(b"%PDF")
        return ConversionResult(
            output_path=output_path,
            provider=providers[0].name,
            from_cache=False,
            credits_used=1,
        )

    monkeypatch.setenv("CLOUDCONVERT_API_KEY", "test-key")
    monkeypatch.setenv("ADOBE_CLIENT_ID", "adobe-id")
    monkeypatch.setenv("ADOBE_CLIENT_SECRET", "adobe-secret")
    monkeypatch.setattr(
        "pdf_remote_converter.cli.commands.get_configured_providers",
        mock_get_configured,
    )
    monkeypatch.setattr(
        "pdf_remote_converter.cli.commands.select_provider",
        mock_select_provider,
    )
    monkeypatch.setattr(
        "pdf_remote_converter.cli.commands.convert_with_failover",
        mock_convert_with_failover,
    )

    result = runner.invoke(
        app,
        ["convert", "--provider", "adobe", str(input_file), str(output_file)],
    )

    assert result.exit_code == 0
    assert selected_provider == "adobe"


def test_cli_help() -> None:
    """Test CLI help text is displayed correctly."""
    result = runner.invoke(app, ["--help"])

    assert result.exit_code == 0
    assert "Converts MS Office documents" in result.stdout


def test_cli_convert_help() -> None:
    """Test convert command help text is available."""
    result = runner.invoke(app, ["convert", "--help"])

    assert result.exit_code == 0
    assert "Input Office file" in result.stdout or "help" in result.stdout.lower()

tests/test_config.py

0 → 100644
+81 −0
Original line number Diff line number Diff line
"""Tests for configuration validation."""

from __future__ import annotations

import pytest
from pydantic import ValidationError

from pdf_remote_converter.config import ProviderSettings


def test_adobe_requires_both_credentials() -> None:
    """Test Adobe credentials validation - both required."""
    with pytest.raises(ValidationError, match="Adobe requires both"):
        ProviderSettings(adobe_client_id="id", adobe_client_secret=None)


def test_adobe_secret_without_id_fails() -> None:
    """Test Adobe secret without ID fails validation."""
    with pytest.raises(ValidationError, match="Adobe requires both"):
        ProviderSettings(adobe_client_id=None, adobe_client_secret="secret")


def test_adobe_credentials_valid_when_both_present() -> None:
    """Test Adobe credentials are valid when both are configured."""
    settings = ProviderSettings(
        adobe_client_id="test-id",
        adobe_client_secret="test-secret",
    )
    creds = settings.get_adobe_credentials()
    assert creds == ("test-id", "test-secret")


def test_config_with_cloudconvert_key() -> None:
    """Test configuration with CloudConvert API key."""
    settings = ProviderSettings(cloudconvert_api_key="cc-key")
    assert settings.get_api_key("cloudconvert") == "cc-key"


def test_config_with_zamzar_key() -> None:
    """Test configuration with Zamzar API key."""
    settings = ProviderSettings(zamzar_api_key="zm-key")
    assert settings.get_api_key("zamzar") == "zm-key"


def test_config_fallback_api_key(monkeypatch) -> None:
    """Test fallback to generic API key when provider-specific not set."""
    monkeypatch.setenv("PDF_REMOTE_CONVERTER_API_KEY", "generic-key")
    settings = ProviderSettings()
    assert settings.get_api_key("cloudconvert") == "generic-key"
    assert settings.get_api_key("zamzar") == "generic-key"


def test_config_provider_specific_overrides_fallback() -> None:
    """Test provider-specific key overrides generic fallback."""
    settings = ProviderSettings(
        cloudconvert_api_key="cc-key",
        api_key="generic-key",
    )
    assert settings.get_api_key("cloudconvert") == "cc-key"


def test_config_cache_dir_default() -> None:
    """Test cache directory has sensible default."""
    settings = ProviderSettings()
    assert ".cache" in str(settings.cache_dir)
    assert "pdf-remote-converter" in str(settings.cache_dir)


def test_config_cache_dir_custom(monkeypatch) -> None:
    """Test custom cache directory path."""
    custom_dir = "/tmp/test-cache"
    monkeypatch.setenv("PDF_REMOTE_CONVERTER_CACHE_DIR", custom_dir)
    settings = ProviderSettings()
    # Compare using resolve() to handle path normalization across platforms
    assert str(settings.cache_dir).replace("\\", "/").endswith("tmp/test-cache")


def test_config_default_provider() -> None:
    """Test default provider is cloudconvert."""
    settings = ProviderSettings()
    assert settings.default_provider == "cloudconvert"

tests/test_logging.py

0 → 100644
+31 −0
Original line number Diff line number Diff line
"""Tests for logging configuration."""

from __future__ import annotations

import logging

from pdf_remote_converter.logging import setup_logging


def test_setup_logging_info_level(caplog) -> None:
    """Test logging setup with INFO level."""
    setup_logging(verbose=False)

    with caplog.at_level(logging.INFO):
        logger = logging.getLogger("test")
        logger.info("test message")

    # Verify logging is configured
    assert len(caplog.records) > 0


def test_setup_logging_debug_level(caplog) -> None:
    """Test logging setup with DEBUG level."""
    setup_logging(verbose=True)

    with caplog.at_level(logging.DEBUG):
        logger = logging.getLogger("test_debug")
        logger.debug("debug message")

    # Verify at least one debug message was logged
    assert any(r.levelno == logging.DEBUG for r in caplog.records)

tests/test_main.py

0 → 100644
+19 −0
Original line number Diff line number Diff line
"""Tests for __main__ module entry point."""

from __future__ import annotations

import subprocess
import sys


def test_main_entry_point() -> None:
    """Test __main__ module can be executed via python -m."""
    result = subprocess.run(
        [sys.executable, "-m", "pdf_remote_converter", "--help"],
        capture_output=True,
        text=True,
        timeout=5,
    )

    assert result.returncode == 0
    assert "convert" in result.stdout.lower() or "help" in result.stdout.lower()
Loading