Add logbook/get_events websocket endpoint (#71706)
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
parent
b9f7d1f54c
commit
04af9698d3
3 changed files with 260 additions and 34 deletions
|
@ -15,7 +15,7 @@ from sqlalchemy.engine.row import Row
|
||||||
from sqlalchemy.orm.query import Query
|
from sqlalchemy.orm.query import Query
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components import frontend
|
from homeassistant.components import frontend, websocket_api
|
||||||
from homeassistant.components.automation import EVENT_AUTOMATION_TRIGGERED
|
from homeassistant.components.automation import EVENT_AUTOMATION_TRIGGERED
|
||||||
from homeassistant.components.history import (
|
from homeassistant.components.history import (
|
||||||
Filters,
|
Filters,
|
||||||
|
@ -23,7 +23,10 @@ from homeassistant.components.history import (
|
||||||
)
|
)
|
||||||
from homeassistant.components.http import HomeAssistantView
|
from homeassistant.components.http import HomeAssistantView
|
||||||
from homeassistant.components.recorder import get_instance
|
from homeassistant.components.recorder import get_instance
|
||||||
from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat
|
from homeassistant.components.recorder.models import (
|
||||||
|
process_datetime_to_timestamp,
|
||||||
|
process_timestamp_to_utc_isoformat,
|
||||||
|
)
|
||||||
from homeassistant.components.recorder.util import session_scope
|
from homeassistant.components.recorder.util import session_scope
|
||||||
from homeassistant.components.script import EVENT_SCRIPT_STARTED
|
from homeassistant.components.script import EVENT_SCRIPT_STARTED
|
||||||
from homeassistant.components.sensor import ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN
|
from homeassistant.components.sensor import ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN
|
||||||
|
@ -102,6 +105,10 @@ LOG_MESSAGE_SCHEMA = vol.Schema(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
LOGBOOK_FILTERS = "logbook_filters"
|
||||||
|
LOGBOOK_ENTITIES_FILTER = "entities_filter"
|
||||||
|
|
||||||
|
|
||||||
@bind_hass
|
@bind_hass
|
||||||
def log_entry(
|
def log_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
@ -168,7 +175,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
filters = None
|
filters = None
|
||||||
entities_filter = None
|
entities_filter = None
|
||||||
|
|
||||||
|
hass.data[LOGBOOK_FILTERS] = filters
|
||||||
|
hass.data[LOGBOOK_ENTITIES_FILTER] = entities_filter
|
||||||
|
|
||||||
hass.http.register_view(LogbookView(conf, filters, entities_filter))
|
hass.http.register_view(LogbookView(conf, filters, entities_filter))
|
||||||
|
websocket_api.async_register_command(hass, ws_get_events)
|
||||||
|
|
||||||
hass.services.async_register(DOMAIN, "log", log_message, schema=LOG_MESSAGE_SCHEMA)
|
hass.services.async_register(DOMAIN, "log", log_message, schema=LOG_MESSAGE_SCHEMA)
|
||||||
|
|
||||||
|
@ -194,6 +205,61 @@ async def _process_logbook_platform(
|
||||||
platform.async_describe_events(hass, _async_describe_event)
|
platform.async_describe_events(hass, _async_describe_event)
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "logbook/get_events",
|
||||||
|
vol.Required("start_time"): str,
|
||||||
|
vol.Optional("end_time"): str,
|
||||||
|
vol.Optional("entity_ids"): [str],
|
||||||
|
vol.Optional("context_id"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def ws_get_events(
|
||||||
|
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
||||||
|
) -> None:
|
||||||
|
"""Handle logbook get events websocket command."""
|
||||||
|
start_time_str = msg["start_time"]
|
||||||
|
end_time_str = msg.get("end_time")
|
||||||
|
utc_now = dt_util.utcnow()
|
||||||
|
|
||||||
|
if start_time := dt_util.parse_datetime(start_time_str):
|
||||||
|
start_time = dt_util.as_utc(start_time)
|
||||||
|
else:
|
||||||
|
connection.send_error(msg["id"], "invalid_start_time", "Invalid start_time")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not end_time_str:
|
||||||
|
end_time = utc_now
|
||||||
|
elif parsed_end_time := dt_util.parse_datetime(end_time_str):
|
||||||
|
end_time = dt_util.as_utc(parsed_end_time)
|
||||||
|
else:
|
||||||
|
connection.send_error(msg["id"], "invalid_end_time", "Invalid end_time")
|
||||||
|
return
|
||||||
|
|
||||||
|
if start_time > utc_now:
|
||||||
|
connection.send_result(msg["id"], {})
|
||||||
|
return
|
||||||
|
|
||||||
|
entity_ids = msg.get("entity_ids")
|
||||||
|
context_id = msg.get("context_id")
|
||||||
|
|
||||||
|
logbook_events: list[dict[str, Any]] = await get_instance(
|
||||||
|
hass
|
||||||
|
).async_add_executor_job(
|
||||||
|
_get_events,
|
||||||
|
hass,
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
|
entity_ids,
|
||||||
|
hass.data[LOGBOOK_FILTERS],
|
||||||
|
hass.data[LOGBOOK_ENTITIES_FILTER],
|
||||||
|
context_id,
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
connection.send_result(msg["id"], logbook_events)
|
||||||
|
|
||||||
|
|
||||||
class LogbookView(HomeAssistantView):
|
class LogbookView(HomeAssistantView):
|
||||||
"""Handle logbook view requests."""
|
"""Handle logbook view requests."""
|
||||||
|
|
||||||
|
@ -267,6 +333,7 @@ class LogbookView(HomeAssistantView):
|
||||||
self.filters,
|
self.filters,
|
||||||
self.entities_filter,
|
self.entities_filter,
|
||||||
context_id,
|
context_id,
|
||||||
|
False,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -281,6 +348,7 @@ def _humanify(
|
||||||
entity_name_cache: EntityNameCache,
|
entity_name_cache: EntityNameCache,
|
||||||
event_cache: EventCache,
|
event_cache: EventCache,
|
||||||
context_augmenter: ContextAugmenter,
|
context_augmenter: ContextAugmenter,
|
||||||
|
format_time: Callable[[Row], Any],
|
||||||
) -> Generator[dict[str, Any], None, None]:
|
) -> Generator[dict[str, Any], None, None]:
|
||||||
"""Generate a converted list of events into Entry objects.
|
"""Generate a converted list of events into Entry objects.
|
||||||
|
|
||||||
|
@ -307,7 +375,7 @@ def _humanify(
|
||||||
continue
|
continue
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"when": _row_time_fired_isoformat(row),
|
"when": format_time(row),
|
||||||
"name": entity_name_cache.get(entity_id, row),
|
"name": entity_name_cache.get(entity_id, row),
|
||||||
"state": row.state,
|
"state": row.state,
|
||||||
"entity_id": entity_id,
|
"entity_id": entity_id,
|
||||||
|
@ -321,21 +389,21 @@ def _humanify(
|
||||||
elif event_type in external_events:
|
elif event_type in external_events:
|
||||||
domain, describe_event = external_events[event_type]
|
domain, describe_event = external_events[event_type]
|
||||||
data = describe_event(event_cache.get(row))
|
data = describe_event(event_cache.get(row))
|
||||||
data["when"] = _row_time_fired_isoformat(row)
|
data["when"] = format_time(row)
|
||||||
data["domain"] = domain
|
data["domain"] = domain
|
||||||
context_augmenter.augment(data, data.get(ATTR_ENTITY_ID), row)
|
context_augmenter.augment(data, data.get(ATTR_ENTITY_ID), row)
|
||||||
yield data
|
yield data
|
||||||
|
|
||||||
elif event_type == EVENT_HOMEASSISTANT_START:
|
elif event_type == EVENT_HOMEASSISTANT_START:
|
||||||
yield {
|
yield {
|
||||||
"when": _row_time_fired_isoformat(row),
|
"when": format_time(row),
|
||||||
"name": "Home Assistant",
|
"name": "Home Assistant",
|
||||||
"message": "started",
|
"message": "started",
|
||||||
"domain": HA_DOMAIN,
|
"domain": HA_DOMAIN,
|
||||||
}
|
}
|
||||||
elif event_type == EVENT_HOMEASSISTANT_STOP:
|
elif event_type == EVENT_HOMEASSISTANT_STOP:
|
||||||
yield {
|
yield {
|
||||||
"when": _row_time_fired_isoformat(row),
|
"when": format_time(row),
|
||||||
"name": "Home Assistant",
|
"name": "Home Assistant",
|
||||||
"message": "stopped",
|
"message": "stopped",
|
||||||
"domain": HA_DOMAIN,
|
"domain": HA_DOMAIN,
|
||||||
|
@ -351,7 +419,7 @@ def _humanify(
|
||||||
domain = split_entity_id(str(entity_id))[0]
|
domain = split_entity_id(str(entity_id))[0]
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"when": _row_time_fired_isoformat(row),
|
"when": format_time(row),
|
||||||
"name": event_data.get(ATTR_NAME),
|
"name": event_data.get(ATTR_NAME),
|
||||||
"message": event_data.get(ATTR_MESSAGE),
|
"message": event_data.get(ATTR_MESSAGE),
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
|
@ -369,6 +437,7 @@ def _get_events(
|
||||||
filters: Filters | None = None,
|
filters: Filters | None = None,
|
||||||
entities_filter: EntityFilter | Callable[[str], bool] | None = None,
|
entities_filter: EntityFilter | Callable[[str], bool] | None = None,
|
||||||
context_id: str | None = None,
|
context_id: str | None = None,
|
||||||
|
timestamp: bool = False,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Get events for a period of time."""
|
"""Get events for a period of time."""
|
||||||
assert not (
|
assert not (
|
||||||
|
@ -386,6 +455,7 @@ def _get_events(
|
||||||
context_lookup, entity_name_cache, external_events, event_cache
|
context_lookup, entity_name_cache, external_events, event_cache
|
||||||
)
|
)
|
||||||
event_types = (*ALL_EVENT_TYPES_EXCEPT_STATE_CHANGED, *external_events)
|
event_types = (*ALL_EVENT_TYPES_EXCEPT_STATE_CHANGED, *external_events)
|
||||||
|
format_time = _row_time_fired_timestamp if timestamp else _row_time_fired_isoformat
|
||||||
|
|
||||||
def yield_rows(query: Query) -> Generator[Row, None, None]:
|
def yield_rows(query: Query) -> Generator[Row, None, None]:
|
||||||
"""Yield Events that are not filtered away."""
|
"""Yield Events that are not filtered away."""
|
||||||
|
@ -424,6 +494,7 @@ def _get_events(
|
||||||
entity_name_cache,
|
entity_name_cache,
|
||||||
event_cache,
|
event_cache,
|
||||||
context_augmenter,
|
context_augmenter,
|
||||||
|
format_time,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -575,9 +646,14 @@ def _row_attributes_extract(row: Row, extractor: re.Pattern) -> str | None:
|
||||||
return result.group(1) if result else None
|
return result.group(1) if result else None
|
||||||
|
|
||||||
|
|
||||||
def _row_time_fired_isoformat(row: Row) -> dt | None:
|
def _row_time_fired_isoformat(row: Row) -> str:
|
||||||
"""Convert the row timed_fired to isoformat."""
|
"""Convert the row timed_fired to isoformat."""
|
||||||
return process_timestamp_to_utc_isoformat(row.time_fired) or dt_util.utcnow()
|
return process_timestamp_to_utc_isoformat(row.time_fired or dt_util.utcnow())
|
||||||
|
|
||||||
|
|
||||||
|
def _row_time_fired_timestamp(row: Row) -> float:
|
||||||
|
"""Convert the row timed_fired to timestamp."""
|
||||||
|
return process_datetime_to_timestamp(row.time_fired or dt_util.utcnow())
|
||||||
|
|
||||||
|
|
||||||
class LazyEventPartialState:
|
class LazyEventPartialState:
|
||||||
|
|
|
@ -53,6 +53,11 @@ def mock_humanify(hass_, rows):
|
||||||
)
|
)
|
||||||
return list(
|
return list(
|
||||||
logbook._humanify(
|
logbook._humanify(
|
||||||
hass_, rows, entity_name_cache, event_cache, context_augmenter
|
hass_,
|
||||||
|
rows,
|
||||||
|
entity_name_cache,
|
||||||
|
event_cache,
|
||||||
|
context_augmenter,
|
||||||
|
logbook._row_time_fired_isoformat,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
@ -4,7 +4,6 @@ import collections
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
@ -13,7 +12,6 @@ import voluptuous as vol
|
||||||
from homeassistant.components import logbook
|
from homeassistant.components import logbook
|
||||||
from homeassistant.components.alexa.smart_home import EVENT_ALEXA_SMART_HOME
|
from homeassistant.components.alexa.smart_home import EVENT_ALEXA_SMART_HOME
|
||||||
from homeassistant.components.automation import EVENT_AUTOMATION_TRIGGERED
|
from homeassistant.components.automation import EVENT_AUTOMATION_TRIGGERED
|
||||||
from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat
|
|
||||||
from homeassistant.components.script import EVENT_SCRIPT_STARTED
|
from homeassistant.components.script import EVENT_SCRIPT_STARTED
|
||||||
from homeassistant.components.sensor import SensorStateClass
|
from homeassistant.components.sensor import SensorStateClass
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
|
@ -42,7 +40,7 @@ from homeassistant.helpers.json import JSONEncoder
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
import homeassistant.util.dt as dt_util
|
import homeassistant.util.dt as dt_util
|
||||||
|
|
||||||
from .common import mock_humanify
|
from .common import MockRow, mock_humanify
|
||||||
|
|
||||||
from tests.common import async_capture_events, mock_platform
|
from tests.common import async_capture_events, mock_platform
|
||||||
from tests.components.recorder.common import (
|
from tests.components.recorder.common import (
|
||||||
|
@ -2140,27 +2138,174 @@ def _assert_entry(
|
||||||
assert state == entry["state"]
|
assert state == entry["state"]
|
||||||
|
|
||||||
|
|
||||||
class MockRow:
|
async def test_get_events(hass, hass_ws_client, recorder_mock):
|
||||||
"""Minimal row mock."""
|
"""Test logbook get_events."""
|
||||||
|
now = dt_util.utcnow()
|
||||||
|
await async_setup_component(hass, "logbook", {})
|
||||||
|
await async_recorder_block_till_done(hass)
|
||||||
|
|
||||||
def __init__(self, event_type: str, data: dict[str, Any] = None):
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
|
||||||
"""Init the fake row."""
|
|
||||||
self.event_type = event_type
|
|
||||||
self.shared_data = json.dumps(data, cls=JSONEncoder)
|
|
||||||
self.data = data
|
|
||||||
self.time_fired = dt_util.utcnow()
|
|
||||||
self.context_parent_id = None
|
|
||||||
self.context_user_id = None
|
|
||||||
self.context_id = None
|
|
||||||
self.state = None
|
|
||||||
self.entity_id = None
|
|
||||||
|
|
||||||
@property
|
hass.states.async_set("light.kitchen", STATE_OFF)
|
||||||
def time_fired_minute(self):
|
await hass.async_block_till_done()
|
||||||
"""Minute the event was fired."""
|
hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 100})
|
||||||
return self.time_fired.minute
|
await hass.async_block_till_done()
|
||||||
|
hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 200})
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 300})
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 400})
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
context = ha.Context(
|
||||||
|
id="ac5bd62de45711eaaeb351041eec8dd9",
|
||||||
|
user_id="b400facee45711eaa9308bfd3d19e474",
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
hass.states.async_set("light.kitchen", STATE_OFF, context=context)
|
||||||
def time_fired_isoformat(self):
|
await hass.async_block_till_done()
|
||||||
"""Time event was fired in utc isoformat."""
|
|
||||||
return process_timestamp_to_utc_isoformat(self.time_fired)
|
await async_wait_recording_done(hass)
|
||||||
|
|
||||||
|
client = await hass_ws_client()
|
||||||
|
await client.send_json(
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "logbook/get_events",
|
||||||
|
"start_time": now.isoformat(),
|
||||||
|
"end_time": now.isoformat(),
|
||||||
|
"entity_ids": ["light.kitchen"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = await client.receive_json()
|
||||||
|
assert response["success"]
|
||||||
|
assert response["result"] == []
|
||||||
|
|
||||||
|
await client.send_json(
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"type": "logbook/get_events",
|
||||||
|
"start_time": now.isoformat(),
|
||||||
|
"entity_ids": ["sensor.test"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = await client.receive_json()
|
||||||
|
assert response["success"]
|
||||||
|
assert response["id"] == 2
|
||||||
|
assert response["result"] == []
|
||||||
|
|
||||||
|
await client.send_json(
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"type": "logbook/get_events",
|
||||||
|
"start_time": now.isoformat(),
|
||||||
|
"entity_ids": ["light.kitchen"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = await client.receive_json()
|
||||||
|
assert response["success"]
|
||||||
|
assert response["id"] == 3
|
||||||
|
|
||||||
|
results = response["result"]
|
||||||
|
assert results[0]["entity_id"] == "light.kitchen"
|
||||||
|
assert results[0]["state"] == "on"
|
||||||
|
assert results[1]["entity_id"] == "light.kitchen"
|
||||||
|
assert results[1]["state"] == "off"
|
||||||
|
|
||||||
|
await client.send_json(
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"type": "logbook/get_events",
|
||||||
|
"start_time": now.isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = await client.receive_json()
|
||||||
|
assert response["success"]
|
||||||
|
assert response["id"] == 4
|
||||||
|
|
||||||
|
results = response["result"]
|
||||||
|
assert len(results) == 3
|
||||||
|
assert results[0]["message"] == "started"
|
||||||
|
assert results[1]["entity_id"] == "light.kitchen"
|
||||||
|
assert results[1]["state"] == "on"
|
||||||
|
assert isinstance(results[1]["when"], float)
|
||||||
|
assert results[2]["entity_id"] == "light.kitchen"
|
||||||
|
assert results[2]["state"] == "off"
|
||||||
|
assert isinstance(results[2]["when"], float)
|
||||||
|
|
||||||
|
await client.send_json(
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"type": "logbook/get_events",
|
||||||
|
"start_time": now.isoformat(),
|
||||||
|
"context_id": "ac5bd62de45711eaaeb351041eec8dd9",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = await client.receive_json()
|
||||||
|
assert response["success"]
|
||||||
|
assert response["id"] == 5
|
||||||
|
|
||||||
|
results = response["result"]
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["entity_id"] == "light.kitchen"
|
||||||
|
assert results[0]["state"] == "off"
|
||||||
|
assert isinstance(results[0]["when"], float)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_events_future_start_time(hass, hass_ws_client, recorder_mock):
|
||||||
|
"""Test get_events with a future start time."""
|
||||||
|
await async_setup_component(hass, "logbook", {})
|
||||||
|
await async_recorder_block_till_done(hass)
|
||||||
|
future = dt_util.utcnow() + timedelta(hours=10)
|
||||||
|
|
||||||
|
client = await hass_ws_client()
|
||||||
|
await client.send_json(
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "logbook/get_events",
|
||||||
|
"start_time": future.isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = await client.receive_json()
|
||||||
|
assert response["success"]
|
||||||
|
assert response["id"] == 1
|
||||||
|
|
||||||
|
results = response["result"]
|
||||||
|
assert len(results) == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_events_bad_start_time(hass, hass_ws_client, recorder_mock):
|
||||||
|
"""Test get_events bad start time."""
|
||||||
|
await async_setup_component(hass, "logbook", {})
|
||||||
|
await async_recorder_block_till_done(hass)
|
||||||
|
|
||||||
|
client = await hass_ws_client()
|
||||||
|
await client.send_json(
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "logbook/get_events",
|
||||||
|
"start_time": "cats",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = await client.receive_json()
|
||||||
|
assert not response["success"]
|
||||||
|
assert response["error"]["code"] == "invalid_start_time"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_events_bad_end_time(hass, hass_ws_client, recorder_mock):
|
||||||
|
"""Test get_events bad end time."""
|
||||||
|
now = dt_util.utcnow()
|
||||||
|
await async_setup_component(hass, "logbook", {})
|
||||||
|
await async_recorder_block_till_done(hass)
|
||||||
|
|
||||||
|
client = await hass_ws_client()
|
||||||
|
await client.send_json(
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "logbook/get_events",
|
||||||
|
"start_time": now.isoformat(),
|
||||||
|
"end_time": "dogs",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response = await client.receive_json()
|
||||||
|
assert not response["success"]
|
||||||
|
assert response["error"]["code"] == "invalid_end_time"
|
||||||
|
|
Loading…
Add table
Reference in a new issue