Files
Adam SmithandClaude Sonnet 5 fa9648213d test: port core's openai_conversation suite as regression net
Ported from home-assistant/core tag 2026.9.2 (tests/components/openai_conversation):
conftest.py fixtures merged into tests/conftest.py, test_config_flow.py,
test_conversation.py and test_ai_task.py copied and rewritten for the fork's
custom_components.openai_compatible / "openai_compatible" domain.

Also copied as local test infrastructure (not core-only, self-contained):
- tests/__init__.py: the openai_conversation test package's own event-builder
  helpers (create_message_item, create_function_tool_call_item, etc.) that
  test_conversation.py and test_ai_task.py import via `from . import ...`.
- tests/snapshots/test_conversation.ambr: just the test_model_args snapshot
  entry, the one surviving syrupy-based assertion (see deletions below).

Deleted from test_conversation.py — depend on tests.components.conversation's
MockChatLog/mock_chat_log fixture, which lives in home-assistant/core's own
tests tree and is not distributable via pytest-homeassistant-custom-component
(confirmed: not present in the installed package's file list):
- test_function_call
- test_function_call_without_reasoning
- test_reasoning_summary_off_omits_summary_key
- test_web_search
- test_web_search_remove_citations_gpt5
- test_code_interpreter

Their now-unused imports (create_code_interpreter_item, create_web_search_item,
CONF_CODE_INTERPRETER, CONF_REASONING_SUMMARY, the CONF_WEB_SEARCH* constants,
freezegun.freeze_time, ToolInput, datetime) were removed with them.

`from tests.common import MockConfigEntry` (core's own tests package, also not
shipped by pytest-homeassistant-custom-component) rewritten to
`from pytest_homeassistant_custom_component.common import MockConfigEntry`,
matching the convention already used in this fork's tests/test_init.py.

Not run locally: this workstation is Python 3.13, HA 2026.9 (and
pytest-homeassistant-custom-component) require 3.14, so no correct local
interpreter exists. Validated with `python3 -m py_compile` only; CI
(actions/setup-python@3.14) is the real test run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01739Seac5eMAfNwFeiwV5r4
2026-09-17 17:36:52 -04:00

354 lines
12 KiB
Python

"""Test AI Task platform of OpenAI Conversation integration."""
from pathlib import Path
from unittest.mock import AsyncMock, patch
import httpx
from openai import PermissionDeniedError
import pytest
import voluptuous as vol
from homeassistant.components import ai_task, media_source
from custom_components.openai_compatible import DOMAIN
from custom_components.openai_compatible.const import CONF_STORE_RESPONSES
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er, issue_registry as ir, selector
from . import create_image_gen_call_item, create_message_item, create_reasoning_item
from pytest_homeassistant_custom_component.common import MockConfigEntry
@pytest.mark.usefixtures("mock_init_component")
@pytest.mark.parametrize("expected_store", [False, True])
async def test_generate_data(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_create_stream: AsyncMock,
entity_registry: er.EntityRegistry,
expected_store: bool,
) -> None:
"""Test AI Task data generation."""
entity_id = "ai_task.openai_ai_task"
# Ensure entity is linked to the subentry
entity_entry = entity_registry.async_get(entity_id)
ai_task_entry = next(
iter(
entry
for entry in mock_config_entry.subentries.values()
if entry.subentry_type == "ai_task_data"
)
)
hass.config_entries.async_update_subentry(
mock_config_entry,
ai_task_entry,
data={**ai_task_entry.data, CONF_STORE_RESPONSES: expected_store},
)
await hass.async_block_till_done()
assert entity_entry is not None
assert entity_entry.config_entry_id == mock_config_entry.entry_id
assert entity_entry.config_subentry_id == ai_task_entry.subentry_id
# Mock the OpenAI response stream
mock_create_stream.return_value = [
create_message_item(id="msg_A", text="The test data", output_index=0)
]
result = await ai_task.async_generate_data(
hass,
task_name="Test Task",
entity_id=entity_id,
instructions="Generate test data",
)
assert result.data == "The test data"
assert mock_create_stream.call_args is not None
assert mock_create_stream.call_args.kwargs["store"] is expected_store
@pytest.mark.usefixtures("mock_init_component")
async def test_generate_structured_data(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_create_stream: AsyncMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test AI Task structured data generation."""
# Mock the OpenAI response stream with JSON data
mock_create_stream.return_value = [
create_message_item(
id="msg_A", text='{"characters": ["Mario", "Luigi"]}', output_index=0
)
]
result = await ai_task.async_generate_data(
hass,
task_name="Test Task",
entity_id="ai_task.openai_ai_task",
instructions="Generate test data",
structure=vol.Schema(
{
vol.Required("characters"): selector.selector(
{
"text": {
"multiple": True,
}
}
)
},
),
)
assert result.data == {"characters": ["Mario", "Luigi"]}
@pytest.mark.usefixtures("mock_init_component")
async def test_generate_invalid_structured_data(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_create_stream: AsyncMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test AI Task with invalid JSON response."""
# Mock the OpenAI response stream with invalid JSON
mock_create_stream.return_value = [
create_message_item(id="msg_A", text="INVALID JSON RESPONSE", output_index=0)
]
with pytest.raises(
HomeAssistantError, match="Error with OpenAI structured response"
):
await ai_task.async_generate_data(
hass,
task_name="Test Task",
entity_id="ai_task.openai_ai_task",
instructions="Generate test data",
structure=vol.Schema(
{
vol.Required("characters"): selector.selector(
{
"text": {
"multiple": True,
}
}
)
},
),
)
@pytest.mark.usefixtures("mock_init_component")
async def test_generate_data_with_attachments(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_create_stream: AsyncMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test AI Task data generation with attachments."""
entity_id = "ai_task.openai_ai_task"
# Mock the OpenAI response stream
mock_create_stream.return_value = [
create_message_item(id="msg_A", text="Hi there!", output_index=0)
]
# Test with attachments
with (
patch(
"homeassistant.components.media_source.async_resolve_media",
side_effect=[
media_source.PlayMedia(
url="http://example.com/doorbell_snapshot.jpg",
mime_type="image/jpeg",
path=Path("doorbell_snapshot.jpg"),
),
media_source.PlayMedia(
url="http://example.com/context.pdf",
mime_type="application/pdf",
path=Path("context.pdf"),
),
],
),
patch("pathlib.Path.exists", return_value=True),
patch(
"custom_components.openai_compatible.entity.guess_file_type",
return_value=("image/jpeg", None),
),
patch("pathlib.Path.read_bytes", return_value=b"fake_image_data"),
):
result = await ai_task.async_generate_data(
hass,
task_name="Test Task",
entity_id=entity_id,
instructions="Test prompt",
attachments=[
{"media_content_id": "media-source://media/doorbell_snapshot.jpg"},
{"media_content_id": "media-source://media/context.pdf"},
],
)
assert result.data == "Hi there!"
# Verify that the create stream was called with the correct parameters
# The last call should have the user message with attachments
call_args = mock_create_stream.call_args
assert call_args is not None
# Check that the input includes the attachments
input_messages = call_args[1]["input"]
assert len(input_messages) > 0
# Find the user message with attachments
user_message_with_attachments = input_messages[-2]
assert user_message_with_attachments is not None
assert isinstance(user_message_with_attachments["content"], list)
assert len(user_message_with_attachments["content"]) == 3 # Text + attachments
assert user_message_with_attachments["content"] == [
{"type": "input_text", "text": "Test prompt"},
{
"detail": "auto",
"image_url": "data:image/jpeg;base64,ZmFrZV9pbWFnZV9kYXRh",
"type": "input_image",
},
{
"filename": "context.pdf",
"file_data": "data:application/pdf;base64,ZmFrZV9pbWFnZV9kYXRh",
"type": "input_file",
},
]
@pytest.mark.usefixtures("mock_init_component")
@pytest.mark.freeze_time("2025-06-14 22:59:00")
@pytest.mark.parametrize("configured_store", [False, True])
@pytest.mark.parametrize(
("image_model", "input_fidelity_present"),
[
("gpt-image-2", False),
("gpt-image-1.5", True),
("gpt-image-1", True),
("gpt-image-1-mini", False),
],
)
async def test_generate_image(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_create_stream: AsyncMock,
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
image_model: str,
input_fidelity_present: bool,
configured_store: bool,
) -> None:
"""Test AI Task image generation."""
entity_id = "ai_task.openai_ai_task"
# Ensure entity is linked to the subentry
entity_entry = entity_registry.async_get(entity_id)
ai_task_entry = next(
iter(
entry
for entry in mock_config_entry.subentries.values()
if entry.subentry_type == "ai_task_data"
)
)
hass.config_entries.async_update_subentry(
mock_config_entry,
ai_task_entry,
data={
**ai_task_entry.data,
"image_model": image_model,
CONF_STORE_RESPONSES: configured_store,
},
)
await hass.async_block_till_done()
assert entity_entry is not None
assert entity_entry.config_entry_id == mock_config_entry.entry_id
assert entity_entry.config_subentry_id == ai_task_entry.subentry_id
# Mock the OpenAI response stream
mock_create_stream.return_value = [
(
*create_reasoning_item(
id="rs_A",
output_index=0,
reasoning_summary=[["The user asks me to generate an image"]],
),
*create_image_gen_call_item(id="ig_A", output_index=1),
*create_message_item(id="msg_A", text="", output_index=2),
)
]
with patch.object(
media_source.local_source.LocalSource,
"async_upload_media",
return_value="media-source://ai_task/image/2025-06-14_155900_test_task.png",
) as mock_upload_media:
result = await ai_task.async_generate_image(
hass,
task_name="Test Task",
entity_id="ai_task.openai_ai_task",
instructions="Generate test image",
)
assert result["height"] == 1024
assert result["width"] == 1536
assert result["revised_prompt"] == "Mock revised prompt."
assert result["mime_type"] == "image/png"
assert result["model"] == image_model
mock_upload_media.assert_called_once()
assert mock_create_stream.call_args is not None
assert mock_create_stream.call_args.kwargs["store"] is True
image_tool = next(
iter(
tool
for tool in mock_create_stream.call_args.kwargs["tools"]
if tool["type"] == "image_generation"
),
)
assert ("input_fidelity" in image_tool) == input_fidelity_present
image_data = mock_upload_media.call_args[0][1]
assert image_data.file.getvalue() == b"A"
assert image_data.content_type == "image/png"
assert image_data.filename == "2025-06-14_155900_test_task.png"
assert (
issue_registry.async_get_issue(DOMAIN, "organization_verification_required")
is None
)
@pytest.mark.usefixtures("mock_init_component")
async def test_repair_issue(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test that repair issue is raised when verification is required."""
with (
patch(
"openai.resources.responses.AsyncResponses.create",
side_effect=PermissionDeniedError(
response=httpx.Response(
status_code=403, request=httpx.Request(method="GET", url="")
),
body=None,
message="Please click on Verify Organization.",
),
),
pytest.raises(HomeAssistantError, match="Error talking to OpenAI"),
):
await ai_task.async_generate_image(
hass,
task_name="Test Task",
entity_id="ai_task.openai_ai_task",
instructions="Generate test image",
)
assert issue_registry.async_get_issue(DOMAIN, "organization_verification_required")