Initial commit.
Audit / Dependencies (push) Successful in 8s
CI / Formatting (push) Successful in 4s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 9s
CI / Tests (Python 3.13) (push) Successful in 12s
CI / Tests (Python 3.14) (push) Successful in 10s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 7s
Audit / Dependencies (push) Successful in 8s
CI / Formatting (push) Successful in 4s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 9s
CI / Tests (Python 3.13) (push) Successful in 12s
CI / Tests (Python 3.14) (push) Successful in 10s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 7s
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Extra Owlbot modules test suite."""
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Pytest configuration for extra Owlbot modules."""
|
||||
|
||||
pytest_plugins = ["owlbot.testing.plugin"]
|
||||
@@ -0,0 +1,529 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for the chat_archive module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import orjson
|
||||
import pytest
|
||||
from owlbot.api import EventType
|
||||
from owlbot.testing import (
|
||||
make_chat_event,
|
||||
make_name_changed_event,
|
||||
make_stream_started_event,
|
||||
make_stream_stopped_event,
|
||||
make_stream_title_updated_event,
|
||||
make_user,
|
||||
make_user_joined_event,
|
||||
make_user_parted_event,
|
||||
make_visibility_update_event,
|
||||
)
|
||||
|
||||
import chat_archive
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
from owlbot.api import ModuleContext
|
||||
from owlbot.registries.events import EventDispatcher
|
||||
from owlbot.testing import RecordingOwncastClient
|
||||
|
||||
|
||||
def _config_data(archive_dir: Path, *, pretty_json: bool = True) -> dict[str, Any]:
|
||||
"""Build plugin config data with chat_archive pointed at a temp directory."""
|
||||
return {
|
||||
"owncast": {"url": "http://localhost:8080"},
|
||||
"owlbot": {"public_base_url": "http://localhost:8081"},
|
||||
"modules": {
|
||||
"chat_archive": {
|
||||
"archive_dir": str(archive_dir),
|
||||
"pretty_json": pretty_json,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def module_pkg() -> ModuleType:
|
||||
"""Auto-wire chat_archive handler registration and lifecycle."""
|
||||
return chat_archive
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def archive_dir(tmp_path: Path) -> Path:
|
||||
"""Return the per-test archive output directory."""
|
||||
return tmp_path / "archives"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_data(archive_dir: Path) -> dict[str, Any]:
|
||||
"""Seed module config so archive writes stay inside tmp_path."""
|
||||
return _config_data(archive_dir)
|
||||
|
||||
|
||||
def _archive_files(archive_dir: Path) -> list[Path]:
|
||||
"""Return archive JSON files in stable filename order."""
|
||||
return sorted(archive_dir.glob("chat-archive-*.json"))
|
||||
|
||||
|
||||
def _read_archive(path: Path) -> dict[str, Any]:
|
||||
"""Read one archive JSON document."""
|
||||
data = orjson.loads(path.read_bytes())
|
||||
assert isinstance(data, dict)
|
||||
return data
|
||||
|
||||
|
||||
def _events(data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Return the typed events list from an archive document."""
|
||||
events = data["events"]
|
||||
assert isinstance(events, list)
|
||||
return cast("list[dict[str, Any]]", events)
|
||||
|
||||
|
||||
def _assert_iso_datetime(value: object) -> None:
|
||||
"""Assert that a value is a timezone-aware ISO datetime string."""
|
||||
assert isinstance(value, str)
|
||||
parsed = datetime.fromisoformat(value)
|
||||
assert parsed.tzinfo is not None
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("module_lifecycle")
|
||||
class TestChatArchive:
|
||||
"""End-to-end behavior through Owlbot's real event dispatch pipeline."""
|
||||
|
||||
async def test_setup_creates_archive_dir_when_stream_is_offline(
|
||||
self,
|
||||
archive_dir: Path,
|
||||
) -> None:
|
||||
"""Offline startup initializes the output directory without an archive."""
|
||||
assert await asyncio.to_thread(archive_dir.is_dir)
|
||||
assert _archive_files(archive_dir) == []
|
||||
|
||||
async def test_stream_started_creates_active_archive(
|
||||
self,
|
||||
archive_dir: Path,
|
||||
event_dispatcher: EventDispatcher,
|
||||
) -> None:
|
||||
"""STREAM_STARTED creates an active stream-scoped archive file."""
|
||||
started_at = datetime(2026, 5, 26, 12, tzinfo=UTC)
|
||||
|
||||
await event_dispatcher.dispatch(
|
||||
EventType.STREAM_STARTED,
|
||||
make_stream_started_event(
|
||||
stream_title="Game Night",
|
||||
timestamp=started_at,
|
||||
),
|
||||
)
|
||||
|
||||
files = _archive_files(archive_dir)
|
||||
assert [path.name for path in files] == ["chat-archive-20260526T120000Z.json"]
|
||||
|
||||
data = _read_archive(files[0])
|
||||
assert data["archive_start_reason"] == "stream_started_event"
|
||||
assert data["archive_stop_reason"] is None
|
||||
assert data["archive_stopped_at"] is None
|
||||
assert data["stream_title"] == "Game Night"
|
||||
assert data["stream_stopped_at"] is None
|
||||
assert data["event_count"] == 0
|
||||
assert data["events"] == []
|
||||
_assert_iso_datetime(data["archive_started_at"])
|
||||
_assert_iso_datetime(data["archive_updated_at"])
|
||||
|
||||
async def test_archives_events_while_stream_is_active(
|
||||
self,
|
||||
archive_dir: Path,
|
||||
event_dispatcher: EventDispatcher,
|
||||
) -> None:
|
||||
"""Supported events append JSON-compatible snapshots in dispatch order."""
|
||||
await event_dispatcher.dispatch(
|
||||
EventType.STREAM_STARTED,
|
||||
make_stream_started_event(
|
||||
stream_title="Event Test",
|
||||
timestamp=datetime(2026, 5, 26, 12, tzinfo=UTC),
|
||||
),
|
||||
)
|
||||
user = make_user(
|
||||
id="alice-id",
|
||||
display_name="Alice",
|
||||
is_authenticated=True,
|
||||
is_moderator=True,
|
||||
scopes=frozenset({"CUSTOM"}),
|
||||
)
|
||||
|
||||
await event_dispatcher.dispatch(
|
||||
EventType.CHAT,
|
||||
make_chat_event(
|
||||
user=user,
|
||||
raw_body="hello chat",
|
||||
body="<p>hello chat</p>",
|
||||
client_id=7,
|
||||
message_id="msg-1",
|
||||
timestamp=datetime(2026, 5, 26, 12, 1, tzinfo=UTC),
|
||||
),
|
||||
)
|
||||
await event_dispatcher.dispatch(
|
||||
EventType.USER_JOINED,
|
||||
make_user_joined_event(
|
||||
user=make_user(id="bob-id", display_name="Bob"),
|
||||
client_id=8,
|
||||
event_id="join-1",
|
||||
timestamp=datetime(2026, 5, 26, 12, 2, tzinfo=UTC),
|
||||
),
|
||||
)
|
||||
await event_dispatcher.dispatch(
|
||||
EventType.VISIBILITY_UPDATE,
|
||||
make_visibility_update_event(
|
||||
event_id="vis-1",
|
||||
message_ids=["msg-1"],
|
||||
is_visible=False,
|
||||
timestamp=datetime(2026, 5, 26, 12, 3, tzinfo=UTC),
|
||||
),
|
||||
)
|
||||
|
||||
data = _read_archive(_archive_files(archive_dir)[0])
|
||||
events = _events(data)
|
||||
assert data["event_count"] == 3
|
||||
assert [event["event_type"] for event in events] == [
|
||||
EventType.CHAT.value,
|
||||
EventType.USER_JOINED.value,
|
||||
EventType.VISIBILITY_UPDATE.value,
|
||||
]
|
||||
|
||||
chat_data = events[0]["event_data"]
|
||||
assert chat_data["message_id"] == "msg-1"
|
||||
assert chat_data["raw_body"] == "hello chat"
|
||||
assert chat_data["body"] == "<p>hello chat</p>"
|
||||
assert chat_data["timestamp"] == "2026-05-26T12:01:00+00:00"
|
||||
assert chat_data["user"]["id"] == "alice-id"
|
||||
assert chat_data["user"]["display_name"] == "Alice"
|
||||
assert chat_data["user"]["scopes"] == ["CUSTOM", "MODERATOR"]
|
||||
|
||||
joined_data = events[1]["event_data"]
|
||||
assert joined_data["event_id"] == "join-1"
|
||||
assert joined_data["user"]["display_name"] == "Bob"
|
||||
|
||||
visibility_data = events[2]["event_data"]
|
||||
assert visibility_data["message_ids"] == ["msg-1"]
|
||||
assert visibility_data["is_visible"] is False
|
||||
_assert_iso_datetime(events[2]["archived_at"])
|
||||
assert data["archive_updated_at"] == events[2]["archived_at"]
|
||||
|
||||
async def test_archives_remaining_supported_event_wrappers(
|
||||
self,
|
||||
archive_dir: Path,
|
||||
event_dispatcher: EventDispatcher,
|
||||
) -> None:
|
||||
"""User part, name change, and title update handlers all archive events."""
|
||||
await event_dispatcher.dispatch(
|
||||
EventType.STREAM_STARTED,
|
||||
make_stream_started_event(
|
||||
timestamp=datetime(2026, 5, 26, 12, tzinfo=UTC),
|
||||
),
|
||||
)
|
||||
|
||||
await event_dispatcher.dispatch(
|
||||
EventType.USER_PARTED,
|
||||
make_user_parted_event(
|
||||
user=make_user(id="alice-id", display_name="Alice"),
|
||||
event_id="part-1",
|
||||
),
|
||||
)
|
||||
await event_dispatcher.dispatch(
|
||||
EventType.NAME_CHANGE,
|
||||
make_name_changed_event(
|
||||
user=make_user(id="alice-id", display_name="AliceNew"),
|
||||
new_name="AliceNew",
|
||||
event_id="name-1",
|
||||
),
|
||||
)
|
||||
await event_dispatcher.dispatch(
|
||||
EventType.STREAM_TITLE_UPDATED,
|
||||
make_stream_title_updated_event(stream_title="New Title"),
|
||||
)
|
||||
|
||||
events = _events(_read_archive(_archive_files(archive_dir)[0]))
|
||||
assert [event["event_type"] for event in events] == [
|
||||
EventType.USER_PARTED.value,
|
||||
EventType.NAME_CHANGE.value,
|
||||
EventType.STREAM_TITLE_UPDATED.value,
|
||||
]
|
||||
assert events[0]["event_data"]["event_id"] == "part-1"
|
||||
assert events[1]["event_data"]["new_name"] == "AliceNew"
|
||||
assert events[2]["event_data"]["stream_title"] == "New Title"
|
||||
|
||||
async def test_naive_stream_start_timestamp_is_treated_as_utc(
|
||||
self,
|
||||
archive_dir: Path,
|
||||
event_dispatcher: EventDispatcher,
|
||||
) -> None:
|
||||
"""Naive stream start timestamps are normalized as UTC for filenames."""
|
||||
await event_dispatcher.dispatch(
|
||||
EventType.STREAM_STARTED,
|
||||
make_stream_started_event(
|
||||
timestamp=datetime(2026, 5, 26, 12), # noqa: DTZ001
|
||||
),
|
||||
)
|
||||
|
||||
assert [path.name for path in _archive_files(archive_dir)] == [
|
||||
"chat-archive-20260526T120000Z.json"
|
||||
]
|
||||
|
||||
async def test_stream_stopped_finalizes_archive_and_ignores_later_events(
|
||||
self,
|
||||
archive_dir: Path,
|
||||
event_dispatcher: EventDispatcher,
|
||||
) -> None:
|
||||
"""STREAM_STOPPED finalizes the active archive and closes capture."""
|
||||
await event_dispatcher.dispatch(
|
||||
EventType.STREAM_STARTED,
|
||||
make_stream_started_event(
|
||||
timestamp=datetime(2026, 5, 26, 12, tzinfo=UTC),
|
||||
),
|
||||
)
|
||||
await event_dispatcher.dispatch(
|
||||
EventType.CHAT,
|
||||
make_chat_event(raw_body="before stop", message_id="msg-before"),
|
||||
)
|
||||
|
||||
stopped_at = datetime(2026, 5, 26, 13, 30, tzinfo=UTC)
|
||||
await event_dispatcher.dispatch(
|
||||
EventType.STREAM_STOPPED,
|
||||
make_stream_stopped_event(timestamp=stopped_at),
|
||||
)
|
||||
|
||||
path = _archive_files(archive_dir)[0]
|
||||
data = _read_archive(path)
|
||||
assert data["archive_stop_reason"] == "stream_stopped_event"
|
||||
assert data["stream_stopped_at"] == "2026-05-26T13:30:00+00:00"
|
||||
assert data["event_count"] == 1
|
||||
_assert_iso_datetime(data["archive_stopped_at"])
|
||||
|
||||
await event_dispatcher.dispatch(
|
||||
EventType.CHAT,
|
||||
make_chat_event(raw_body="after stop", message_id="msg-after"),
|
||||
)
|
||||
|
||||
assert _read_archive(path)["event_count"] == 1
|
||||
|
||||
async def test_new_stream_replaces_active_archive_and_uses_filename_suffix(
|
||||
self,
|
||||
archive_dir: Path,
|
||||
event_dispatcher: EventDispatcher,
|
||||
) -> None:
|
||||
"""A second stream start finalizes the first archive and opens a suffix file."""
|
||||
started_at = datetime(2026, 5, 26, 12, tzinfo=UTC)
|
||||
await event_dispatcher.dispatch(
|
||||
EventType.STREAM_STARTED,
|
||||
make_stream_started_event(stream_title="First", timestamp=started_at),
|
||||
)
|
||||
await event_dispatcher.dispatch(
|
||||
EventType.STREAM_STARTED,
|
||||
make_stream_started_event(stream_title="Second", timestamp=started_at),
|
||||
)
|
||||
await event_dispatcher.dispatch(
|
||||
EventType.CHAT,
|
||||
make_chat_event(raw_body="new stream message", message_id="msg-new"),
|
||||
)
|
||||
|
||||
files = _archive_files(archive_dir)
|
||||
assert {path.name for path in files} == {
|
||||
"chat-archive-20260526T120000Z.json",
|
||||
"chat-archive-20260526T120000Z-2.json",
|
||||
}
|
||||
|
||||
old_archive = _read_archive(archive_dir / "chat-archive-20260526T120000Z.json")
|
||||
assert old_archive["archive_stop_reason"] == "replaced_by_stream_started_event"
|
||||
assert old_archive["event_count"] == 0
|
||||
_assert_iso_datetime(old_archive["archive_stopped_at"])
|
||||
|
||||
new_archive = _read_archive(
|
||||
archive_dir / "chat-archive-20260526T120000Z-2.json"
|
||||
)
|
||||
assert new_archive["archive_start_reason"] == "stream_started_event"
|
||||
assert new_archive["archive_stop_reason"] is None
|
||||
assert new_archive["stream_title"] == "Second"
|
||||
assert new_archive["event_count"] == 1
|
||||
assert _events(new_archive)[0]["event_data"]["message_id"] == "msg-new"
|
||||
|
||||
|
||||
class TestSetupAndTeardown:
|
||||
"""Cases that need direct control around setup and teardown timing."""
|
||||
|
||||
def test_get_state_raises_before_setup(self, module_context: ModuleContext) -> None:
|
||||
"""Accessing module state before setup reports a clear runtime error."""
|
||||
with pytest.raises(RuntimeError, match="chat_archive state is not initialized"):
|
||||
chat_archive._get_state(module_context)
|
||||
|
||||
async def test_setup_continues_when_status_check_fails(
|
||||
self,
|
||||
archive_dir: Path,
|
||||
module_context: ModuleContext,
|
||||
owncast_client: RecordingOwncastClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A startup status failure leaves the module ready for future events."""
|
||||
monkeypatch.setattr(
|
||||
owncast_client,
|
||||
"get_status",
|
||||
AsyncMock(side_effect=RuntimeError("status failed")),
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
await chat_archive.setup(module_context)
|
||||
try:
|
||||
assert await asyncio.to_thread(archive_dir.is_dir)
|
||||
assert _archive_files(archive_dir) == []
|
||||
assert "Could not check stream status" in caplog.text
|
||||
finally:
|
||||
await chat_archive.teardown(module_context)
|
||||
|
||||
async def test_setup_starts_archive_when_stream_is_already_online(
|
||||
self,
|
||||
archive_dir: Path,
|
||||
module_context: ModuleContext,
|
||||
owncast_client: RecordingOwncastClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Direct setup can observe a stubbed live Owncast status response."""
|
||||
monkeypatch.setattr(
|
||||
owncast_client,
|
||||
"get_status",
|
||||
AsyncMock(
|
||||
return_value={
|
||||
"online": True,
|
||||
"lastConnectTime": "2026-05-26T12:00:00+00:00",
|
||||
"streamTitle": "Already Live",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
await chat_archive.setup(module_context)
|
||||
try:
|
||||
files = _archive_files(archive_dir)
|
||||
assert [path.name for path in files] == [
|
||||
"chat-archive-20260526T120000Z.json"
|
||||
]
|
||||
data = _read_archive(files[0])
|
||||
assert data["archive_start_reason"] == "module_startup"
|
||||
assert data["stream_title"] == "Already Live"
|
||||
assert data["archive_stop_reason"] is None
|
||||
finally:
|
||||
await chat_archive.teardown(module_context)
|
||||
|
||||
async def test_setup_logs_bad_last_connect_time_and_starts_archive(
|
||||
self,
|
||||
archive_dir: Path,
|
||||
module_context: ModuleContext,
|
||||
owncast_client: RecordingOwncastClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Malformed lastConnectTime falls back to the current time."""
|
||||
monkeypatch.setattr(
|
||||
owncast_client,
|
||||
"get_status",
|
||||
AsyncMock(
|
||||
return_value={
|
||||
"online": True,
|
||||
"lastConnectTime": "not-a-date",
|
||||
"streamTitle": "Bad Timestamp",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
await chat_archive.setup(module_context)
|
||||
try:
|
||||
files = _archive_files(archive_dir)
|
||||
assert len(files) == 1
|
||||
data = _read_archive(files[0])
|
||||
assert data["archive_start_reason"] == "module_startup"
|
||||
assert data["stream_title"] == "Bad Timestamp"
|
||||
assert "lastConnectTime in unexpected format" in caplog.text
|
||||
finally:
|
||||
await chat_archive.teardown(module_context)
|
||||
|
||||
@pytest.mark.usefixtures("registered_handlers")
|
||||
async def test_teardown_finalizes_active_archive(
|
||||
self,
|
||||
archive_dir: Path,
|
||||
module_context: ModuleContext,
|
||||
event_dispatcher: EventDispatcher,
|
||||
) -> None:
|
||||
"""Module teardown writes the final archive state for an active stream."""
|
||||
await chat_archive.setup(module_context)
|
||||
await event_dispatcher.dispatch(
|
||||
EventType.STREAM_STARTED,
|
||||
make_stream_started_event(
|
||||
timestamp=datetime(2026, 5, 26, 12, tzinfo=UTC),
|
||||
),
|
||||
)
|
||||
|
||||
path = _archive_files(archive_dir)[0]
|
||||
await chat_archive.teardown(module_context)
|
||||
|
||||
data = _read_archive(path)
|
||||
assert data["archive_stop_reason"] == "module_teardown"
|
||||
assert data["stream_stopped_at"] is None
|
||||
_assert_iso_datetime(data["archive_stopped_at"])
|
||||
|
||||
|
||||
class TestConfiguredOutput:
|
||||
"""Configuration-dependent archive output behavior."""
|
||||
|
||||
@pytest.fixture
|
||||
def config_data(self, archive_dir: Path) -> dict[str, Any]:
|
||||
"""Disable pretty JSON for tests in this class."""
|
||||
return _config_data(archive_dir, pretty_json=False)
|
||||
|
||||
@pytest.mark.usefixtures("module_lifecycle")
|
||||
async def test_pretty_json_false_writes_compact_json(
|
||||
self,
|
||||
archive_dir: Path,
|
||||
event_dispatcher: EventDispatcher,
|
||||
) -> None:
|
||||
"""pretty_json=False writes one compact JSON line plus a trailing newline."""
|
||||
await event_dispatcher.dispatch(
|
||||
EventType.STREAM_STARTED,
|
||||
make_stream_started_event(
|
||||
timestamp=datetime(2026, 5, 26, 12, tzinfo=UTC),
|
||||
),
|
||||
)
|
||||
|
||||
content = _archive_files(archive_dir)[0].read_bytes()
|
||||
assert content.startswith(b'{"archive_start_reason"')
|
||||
assert b'\n "' not in content
|
||||
assert content.endswith(b"\n")
|
||||
assert _read_archive(_archive_files(archive_dir)[0])["event_count"] == 0
|
||||
|
||||
async def test_write_json_rejects_unserializable_objects(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""The JSON writer surfaces unsupported value types."""
|
||||
with pytest.raises(TypeError, match="Type is not JSON serializable: object"):
|
||||
await chat_archive._write_json(
|
||||
tmp_path / "bad.json",
|
||||
{"bad": object()},
|
||||
pretty_json=True,
|
||||
)
|
||||
Reference in New Issue
Block a user