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

This commit is contained in:
2026-05-27 10:32:49 -04:00
commit 5d113eecaa
13 changed files with 3087 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Extra Owlbot modules test suite."""
+3
View File
@@ -0,0 +1,3 @@
"""Pytest configuration for extra Owlbot modules."""
pytest_plugins = ["owlbot.testing.plugin"]
+529
View File
@@ -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,
)