"""Integration tests for bulkgen.providers (image and text). Mock-heavy tests produce many Any-typed expressions from MagicMock. """ # pyright: reportAny=false from __future__ import annotations import base64 from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest from bulkgen.config import TargetConfig from bulkgen.providers.image import ImageProvider from bulkgen.providers.image import ( _encode_image_b64 as encode_image_b64, # pyright: ignore[reportPrivateUsage] ) from bulkgen.providers.text import TextProvider def _make_bfl_mocks( image_bytes: bytes, ) -> tuple[MagicMock, MagicMock]: """Return (mock_result, mock_http) for BFL image generation tests.""" mock_result = MagicMock() mock_result.result = {"sample": "https://example.com/img.png"} mock_response = MagicMock() mock_response.content = image_bytes mock_response.raise_for_status.return_value = None mock_http = AsyncMock() mock_http.get.return_value = mock_response mock_http.__aenter__ = AsyncMock(return_value=mock_http) mock_http.__aexit__ = AsyncMock(return_value=False) return mock_result, mock_http def _make_mistral_mock(response: MagicMock) -> AsyncMock: """Return a mock Mistral client.""" mock_client = AsyncMock() mock_client.chat.complete_async.return_value = response mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) return mock_client def _make_text_response(content: str | None) -> MagicMock: """Return a mock Mistral response with one choice.""" choice = MagicMock() choice.message.content = content response = MagicMock() response.choices = [choice] return response class TestImageProvider: """Test ImageProvider with mocked BFL client and HTTP.""" @pytest.fixture def image_bytes(self) -> bytes: return b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 async def test_basic_image_generation( self, project_dir: Path, image_bytes: bytes ) -> None: target_config = TargetConfig(prompt="A red square") mock_result, mock_http = _make_bfl_mocks(image_bytes) with ( patch("bulkgen.providers.image.BFLClient") as mock_cls, patch("bulkgen.providers.image.isinstance", return_value=True), patch("bulkgen.providers.image.httpx.AsyncClient") as mock_http_cls, ): mock_cls.return_value.generate.return_value = mock_result mock_http_cls.return_value = mock_http provider = ImageProvider(api_key="test-key") await provider.generate( target_name="out.png", target_config=target_config, resolved_prompt="A red square", resolved_model="flux-pro-1.1", project_dir=project_dir, ) output = project_dir / "out.png" assert output.exists() assert output.read_bytes() == image_bytes async def test_image_with_dimensions( self, project_dir: Path, image_bytes: bytes ) -> None: target_config = TargetConfig(prompt="A banner", width=1920, height=480) mock_result, mock_http = _make_bfl_mocks(image_bytes) with ( patch("bulkgen.providers.image.BFLClient") as mock_cls, patch("bulkgen.providers.image.isinstance", return_value=True), patch("bulkgen.providers.image.httpx.AsyncClient") as mock_http_cls, ): client_instance = mock_cls.return_value client_instance.generate.return_value = mock_result mock_http_cls.return_value = mock_http provider = ImageProvider(api_key="test-key") await provider.generate( target_name="banner.png", target_config=target_config, resolved_prompt="A banner", resolved_model="flux-pro-1.1", project_dir=project_dir, ) call_args = client_instance.generate.call_args inputs = call_args[0][1] assert inputs["width"] == 1920 assert inputs["height"] == 480 async def test_image_with_reference_image( self, project_dir: Path, image_bytes: bytes ) -> None: ref_path = project_dir / "ref.png" _ = ref_path.write_bytes(b"reference image data") target_config = TargetConfig(prompt="Like this", reference_image="ref.png") mock_result, mock_http = _make_bfl_mocks(image_bytes) with ( patch("bulkgen.providers.image.BFLClient") as mock_cls, patch("bulkgen.providers.image.isinstance", return_value=True), patch("bulkgen.providers.image.httpx.AsyncClient") as mock_http_cls, ): client_instance = mock_cls.return_value client_instance.generate.return_value = mock_result mock_http_cls.return_value = mock_http provider = ImageProvider(api_key="test-key") await provider.generate( target_name="out.png", target_config=target_config, resolved_prompt="Like this", resolved_model="flux-kontext-pro", project_dir=project_dir, ) call_args = client_instance.generate.call_args inputs = call_args[0][1] assert "image_prompt" in inputs assert inputs["image_prompt"] == encode_image_b64(ref_path) async def test_image_no_sample_url_raises(self, project_dir: Path) -> None: target_config = TargetConfig(prompt="x") mock_result = MagicMock() mock_result.result = {} with ( patch("bulkgen.providers.image.BFLClient") as mock_cls, patch("bulkgen.providers.image.isinstance", return_value=True), ): mock_cls.return_value.generate.return_value = mock_result provider = ImageProvider(api_key="test-key") with pytest.raises(RuntimeError, match="did not return an image URL"): await provider.generate( target_name="fail.png", target_config=target_config, resolved_prompt="x", resolved_model="flux-pro", project_dir=project_dir, ) def test_encode_image_b64(self, project_dir: Path) -> None: data = b"test image bytes" f = project_dir / "test.png" _ = f.write_bytes(data) encoded = encode_image_b64(f) assert base64.b64decode(encoded) == data class TestTextProvider: """Test TextProvider with mocked Mistral client.""" async def test_basic_text_generation(self, project_dir: Path) -> None: target_config = TargetConfig(prompt="Write a poem") response = _make_text_response("Roses are red...") with patch("bulkgen.providers.text.Mistral") as mock_cls: mock_cls.return_value = _make_mistral_mock(response) provider = TextProvider(api_key="test-key") await provider.generate( target_name="poem.txt", target_config=target_config, resolved_prompt="Write a poem", resolved_model="mistral-large-latest", project_dir=project_dir, ) output = project_dir / "poem.txt" assert output.exists() assert output.read_text() == "Roses are red..." async def test_text_with_text_input(self, project_dir: Path) -> None: _ = (project_dir / "source.txt").write_text("Source material here") target_config = TargetConfig(prompt="Summarize", inputs=["source.txt"]) response = _make_text_response("Summary: ...") with patch("bulkgen.providers.text.Mistral") as mock_cls: mock_client = _make_mistral_mock(response) mock_cls.return_value = mock_client provider = TextProvider(api_key="test-key") await provider.generate( target_name="summary.md", target_config=target_config, resolved_prompt="Summarize", resolved_model="mistral-large-latest", project_dir=project_dir, ) call_args = mock_client.chat.complete_async.call_args messages = call_args.kwargs["messages"] prompt_text = messages[0].content assert "--- Contents of source.txt ---" in prompt_text assert "Source material here" in prompt_text async def test_text_with_image_input(self, project_dir: Path) -> None: _ = (project_dir / "photo.png").write_bytes(b"\x89PNG") target_config = TargetConfig(prompt="Describe this image", inputs=["photo.png"]) response = _make_text_response("A beautiful photo") with patch("bulkgen.providers.text.Mistral") as mock_cls: mock_client = _make_mistral_mock(response) mock_cls.return_value = mock_client provider = TextProvider(api_key="test-key") await provider.generate( target_name="desc.txt", target_config=target_config, resolved_prompt="Describe this image", resolved_model="mistral-large-latest", project_dir=project_dir, ) call_args = mock_client.chat.complete_async.call_args messages = call_args.kwargs["messages"] prompt_text = messages[0].content assert "[Attached image: photo.png]" in prompt_text async def test_text_no_choices_raises(self, project_dir: Path) -> None: target_config = TargetConfig(prompt="x") response = MagicMock() response.choices = [] with patch("bulkgen.providers.text.Mistral") as mock_cls: mock_cls.return_value = _make_mistral_mock(response) provider = TextProvider(api_key="test-key") with pytest.raises(RuntimeError, match="no choices"): await provider.generate( target_name="fail.txt", target_config=target_config, resolved_prompt="x", resolved_model="mistral-large-latest", project_dir=project_dir, ) async def test_text_empty_content_raises(self, project_dir: Path) -> None: target_config = TargetConfig(prompt="x") response = _make_text_response(None) with patch("bulkgen.providers.text.Mistral") as mock_cls: mock_cls.return_value = _make_mistral_mock(response) provider = TextProvider(api_key="test-key") with pytest.raises(RuntimeError, match="empty content"): await provider.generate( target_name="fail.txt", target_config=target_config, resolved_prompt="x", resolved_model="mistral-large-latest", project_dir=project_dir, ) async def test_text_with_multiple_inputs(self, project_dir: Path) -> None: _ = (project_dir / "a.txt").write_text("content A") _ = (project_dir / "b.txt").write_text("content B") _ = (project_dir / "c.png").write_bytes(b"\x89PNG") target_config = TargetConfig( prompt="Combine all", inputs=["a.txt", "b.txt", "c.png"] ) response = _make_text_response("Combined") with patch("bulkgen.providers.text.Mistral") as mock_cls: mock_client = _make_mistral_mock(response) mock_cls.return_value = mock_client provider = TextProvider(api_key="test-key") await provider.generate( target_name="out.md", target_config=target_config, resolved_prompt="Combine all", resolved_model="mistral-large-latest", project_dir=project_dir, ) call_args = mock_client.chat.complete_async.call_args prompt_text = call_args.kwargs["messages"][0].content assert "--- Contents of a.txt ---" in prompt_text assert "content A" in prompt_text assert "--- Contents of b.txt ---" in prompt_text assert "content B" in prompt_text assert "[Attached image: c.png]" in prompt_text