feat: implement core/ and platform/ with full test coverage

- platform/interface.py: PlatformClient Protocol + Pydantic models (User,
  MessageResponse, UserSettings) — no explicit session management, Master
  handles container lifecycle
- platform/mock.py: MockPlatformClient with simulated latency, [MOCK]
  responses, is_new correctly True only on first creation
- core/protocol.py: unified dataclasses for all events and responses
  (IncomingMessage/Command/Callback, OutgoingMessage/UI/Notification,
  AuthFlow, ChatContext, SettingsAction, etc.)
- core/store.py: StateStore Protocol + InMemoryStore (tests) + SQLiteStore
  (prod) with JSON serialization
- core/chat.py: ChatManager — chat metadata (C1/C2/C3), not container
  lifecycle (that's the platform's job)
- core/auth.py: AuthManager — start_flow / confirm / is_authenticated
- core/settings.py: SettingsManager — get/apply with store cache
- core/handler.py: EventDispatcher — registry-based routing with keys
  (command name, action name, attachment type, "*" catch-all)
- core/handlers/: register_all() + start/new/message/callback/settings
  handlers; voice slot falls back to stub text until voice_handler added
- conftest.py: sys.path fix so local platform/ shadows stdlib platform
- docs/api-contract.md: rewritten for Lambda Lab 3.0 container model

46 tests passing, 0 warnings.
This commit is contained in:
Mikhail Putilovskij 2026-03-29 00:48:19 +03:00
parent 944c383552
commit 36730ae716
27 changed files with 1315 additions and 3 deletions

38
tests/core/test_auth.py Normal file
View file

@ -0,0 +1,38 @@
# tests/core/test_auth.py
import pytest
from core.auth import AuthManager
from core.store import InMemoryStore
from platform.mock import MockPlatformClient
@pytest.fixture
def mgr():
return AuthManager(MockPlatformClient(), InMemoryStore())
async def test_not_authenticated_initially(mgr):
assert await mgr.is_authenticated("u1") is False
async def test_start_flow_returns_pending(mgr):
flow = await mgr.start_flow("u1", "telegram")
assert flow.state == "pending"
assert flow.user_id == "u1"
async def test_confirm_sets_confirmed(mgr):
await mgr.start_flow("u1", "telegram")
flow = await mgr.confirm("u1")
assert flow.state == "confirmed"
async def test_is_authenticated_after_confirm(mgr):
await mgr.start_flow("u1", "telegram")
await mgr.confirm("u1")
assert await mgr.is_authenticated("u1") is True
async def test_confirm_without_start_flow(mgr):
flow = await mgr.confirm("new_user")
assert flow.state == "confirmed"
assert await mgr.is_authenticated("new_user") is True

53
tests/core/test_chat.py Normal file
View file

@ -0,0 +1,53 @@
# tests/core/test_chat.py
import pytest
from core.chat import ChatManager
from core.store import InMemoryStore
from platform.mock import MockPlatformClient
@pytest.fixture
def mgr():
return ChatManager(MockPlatformClient(), InMemoryStore())
async def test_get_or_create_new_chat(mgr):
ctx = await mgr.get_or_create("u1", "C1", "telegram", "topic-123")
assert ctx.chat_id == "C1"
assert ctx.platform == "telegram"
assert ctx.is_archived is False
async def test_get_or_create_idempotent(mgr):
c1 = await mgr.get_or_create("u1", "C1", "telegram", "t1")
c2 = await mgr.get_or_create("u1", "C1", "telegram", "t1")
assert c1.chat_id == c2.chat_id
assert c1.display_name == c2.display_name
async def test_get_or_create_with_custom_name(mgr):
ctx = await mgr.get_or_create("u1", "C1", "telegram", "t1", name="Анализ рынка")
assert ctx.display_name == "Анализ рынка"
async def test_rename_chat(mgr):
await mgr.get_or_create("u1", "C1", "telegram", "t1")
ctx = await mgr.rename("C1", "Новое название")
assert ctx.display_name == "Новое название"
async def test_archive_chat(mgr):
await mgr.get_or_create("u1", "C1", "telegram", "t1")
await mgr.archive("C1")
ctx = await mgr.get("C1")
assert ctx is not None
assert ctx.is_archived is True
async def test_list_active_excludes_archived(mgr):
await mgr.get_or_create("u1", "C1", "telegram", "t1")
await mgr.get_or_create("u1", "C2", "telegram", "t2")
await mgr.archive("C2")
chats = await mgr.list_active("u1")
ids = [c.chat_id for c in chats]
assert "C1" in ids
assert "C2" not in ids

View file

@ -0,0 +1,85 @@
# tests/core/test_dispatcher.py
import pytest
from core.handler import EventDispatcher
from core.protocol import (
IncomingCommand, IncomingMessage, IncomingCallback,
OutgoingMessage, Attachment,
)
from core.chat import ChatManager
from core.auth import AuthManager
from core.settings import SettingsManager
from core.store import InMemoryStore
from platform.mock import MockPlatformClient
@pytest.fixture
def dispatcher():
platform = MockPlatformClient()
store = InMemoryStore()
return EventDispatcher(
platform=platform,
chat_mgr=ChatManager(platform, store),
auth_mgr=AuthManager(platform, store),
settings_mgr=SettingsManager(platform, store),
)
async def test_dispatch_command_to_handler(dispatcher):
called_with = {}
async def my_handler(event, **kwargs):
called_with["event"] = event
return [OutgoingMessage(chat_id=event.chat_id, text="ok")]
dispatcher.register(IncomingCommand, "ping", my_handler)
cmd = IncomingCommand(user_id="u1", platform="telegram", chat_id="C1", command="ping")
result = await dispatcher.dispatch(cmd)
assert called_with["event"] is cmd
assert result[0].text == "ok"
async def test_dispatch_unknown_command_returns_empty(dispatcher):
cmd = IncomingCommand(user_id="u1", platform="telegram", chat_id="C1", command="unknown")
result = await dispatcher.dispatch(cmd)
assert result == []
async def test_dispatch_message_to_catchall(dispatcher):
async def catch_all(event, **kwargs):
return [OutgoingMessage(chat_id=event.chat_id, text="caught")]
dispatcher.register(IncomingMessage, "*", catch_all)
msg = IncomingMessage(user_id="u1", platform="telegram", chat_id="C1", text="hello")
result = await dispatcher.dispatch(msg)
assert result[0].text == "caught"
async def test_dispatch_routes_audio_before_catchall(dispatcher):
async def audio_handler(event, **kwargs):
return [OutgoingMessage(chat_id=event.chat_id, text="audio")]
async def catch_all(event, **kwargs):
return [OutgoingMessage(chat_id=event.chat_id, text="text")]
dispatcher.register(IncomingMessage, "audio", audio_handler)
dispatcher.register(IncomingMessage, "*", catch_all)
audio_msg = IncomingMessage(
user_id="u1", platform="telegram", chat_id="C1", text="",
attachments=[Attachment(type="audio")],
)
text_msg = IncomingMessage(user_id="u1", platform="telegram", chat_id="C1", text="hi")
assert (await dispatcher.dispatch(audio_msg))[0].text == "audio"
assert (await dispatcher.dispatch(text_msg))[0].text == "text"
async def test_dispatch_callback_by_action(dispatcher):
async def confirm_handler(event, **kwargs):
return [OutgoingMessage(chat_id=event.chat_id, text="confirmed")]
dispatcher.register(IncomingCallback, "confirm", confirm_handler)
cb = IncomingCallback(user_id="u1", platform="telegram", chat_id="C1", action="confirm")
result = await dispatcher.dispatch(cb)
assert result[0].text == "confirmed"

View file

@ -0,0 +1,85 @@
# tests/core/test_integration.py
"""
Smoke test: полный цикл через dispatcher + реальные managers + MockPlatformClient.
Имитирует что делает адаптер (Telegram или Matrix) при получении события.
"""
import pytest
from platform.mock import MockPlatformClient
from core.store import InMemoryStore
from core.chat import ChatManager
from core.auth import AuthManager
from core.settings import SettingsManager
from core.handler import EventDispatcher
from core.handlers import register_all
from core.protocol import (
IncomingCommand, IncomingMessage, IncomingCallback,
OutgoingMessage, OutgoingUI,
Attachment, SettingsAction,
)
@pytest.fixture
def dispatcher():
platform = MockPlatformClient()
store = InMemoryStore()
d = EventDispatcher(
platform=platform,
chat_mgr=ChatManager(platform, store),
auth_mgr=AuthManager(platform, store),
settings_mgr=SettingsManager(platform, store),
)
register_all(d)
return d
async def test_full_flow_start_then_message(dispatcher):
start = IncomingCommand(user_id="tg_123", platform="telegram", chat_id="C1", command="start")
result = await dispatcher.dispatch(start)
assert any(isinstance(r, OutgoingMessage) for r in result)
msg = IncomingMessage(user_id="tg_123", platform="telegram", chat_id="C1", text="Привет!")
result = await dispatcher.dispatch(msg)
texts = [r.text for r in result if isinstance(r, OutgoingMessage)]
assert any("[MOCK]" in t for t in texts)
async def test_new_chat_command(dispatcher):
start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start")
await dispatcher.dispatch(start)
new = IncomingCommand(user_id="u1", platform="matrix", chat_id="C2", command="new", args=["Анализ"])
result = await dispatcher.dispatch(new)
assert any("Анализ" in r.text for r in result if isinstance(r, OutgoingMessage))
async def test_settings_menu(dispatcher):
start = IncomingCommand(user_id="u1", platform="telegram", chat_id="C1", command="start")
await dispatcher.dispatch(start)
s = IncomingCommand(user_id="u1", platform="telegram", chat_id="C1", command="settings")
result = await dispatcher.dispatch(s)
assert any(isinstance(r, OutgoingUI) for r in result)
async def test_voice_message_fallback(dispatcher):
start = IncomingCommand(user_id="u1", platform="telegram", chat_id="C1", command="start")
await dispatcher.dispatch(start)
voice = IncomingMessage(
user_id="u1", platform="telegram", chat_id="C1", text="",
attachments=[Attachment(type="audio")],
)
result = await dispatcher.dispatch(voice)
assert any("голосов" in r.text.lower() for r in result if isinstance(r, OutgoingMessage))
async def test_toggle_skill_callback(dispatcher):
start = IncomingCommand(user_id="u1", platform="telegram", chat_id="C1", command="start")
await dispatcher.dispatch(start)
cb = IncomingCallback(
user_id="u1", platform="telegram", chat_id="C1",
action="toggle_skill", payload={"skill": "browser", "enabled": True},
)
result = await dispatcher.dispatch(cb)
assert any("browser" in r.text for r in result if isinstance(r, OutgoingMessage))

View file

@ -0,0 +1,42 @@
# tests/core/test_protocol.py
from datetime import datetime
from core.protocol import (
Attachment, IncomingMessage, IncomingCommand, IncomingCallback,
OutgoingMessage, OutgoingUI, OutgoingTyping, OutgoingNotification,
UIButton, ChatContext, AuthFlow, ConfirmationRequest, SettingsAction, PaymentRequired,
)
def test_incoming_message_defaults():
msg = IncomingMessage(user_id="u1", platform="telegram", chat_id="C1", text="hi")
assert msg.attachments == []
assert msg.reply_to is None
def test_attachment_audio():
a = Attachment(type="audio", filename="voice.ogg", mime_type="audio/ogg")
assert a.type == "audio"
assert a.url is None
def test_incoming_command_defaults():
cmd = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="new")
assert cmd.args == []
def test_outgoing_message_defaults():
msg = OutgoingMessage(chat_id="C1", text="hello")
assert msg.parse_mode == "plain"
assert msg.attachments == []
def test_ui_button_defaults():
btn = UIButton(label="OK", action="confirm")
assert btn.style == "secondary"
assert btn.payload == {}
def test_settings_action():
action = SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True})
assert action.action == "toggle_skill"
assert action.payload["skill"] == "browser"

View file

@ -0,0 +1,32 @@
# tests/core/test_settings.py
import pytest
from core.settings import SettingsManager
from core.store import InMemoryStore
from core.protocol import SettingsAction
from platform.mock import MockPlatformClient
@pytest.fixture
def mgr():
return SettingsManager(MockPlatformClient(), InMemoryStore())
async def test_get_returns_defaults(mgr):
settings = await mgr.get("u1")
assert "web-search" in settings.skills
async def test_apply_toggle_skill(mgr):
action = SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True})
await mgr.apply("u1", action)
settings = await mgr.get("u1")
assert settings.skills.get("browser") is True
async def test_apply_invalidates_cache(mgr):
s1 = await mgr.get("u1")
initial = s1.skills.get("browser", False)
action = SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": not initial})
await mgr.apply("u1", action)
s2 = await mgr.get("u1")
assert s2.skills.get("browser") == (not initial)

58
tests/core/test_store.py Normal file
View file

@ -0,0 +1,58 @@
# tests/core/test_store.py
from core.store import InMemoryStore, SQLiteStore
async def test_inmemory_get_missing_returns_none():
store = InMemoryStore()
assert await store.get("missing") is None
async def test_inmemory_set_and_get():
store = InMemoryStore()
await store.set("k", {"x": 1})
assert await store.get("k") == {"x": 1}
async def test_inmemory_delete():
store = InMemoryStore()
await store.set("k", {"x": 1})
await store.delete("k")
assert await store.get("k") is None
async def test_inmemory_keys_prefix():
store = InMemoryStore()
await store.set("chat:u1:C1", {"a": 1})
await store.set("chat:u1:C2", {"b": 2})
await store.set("auth:u1", {"c": 3})
keys = await store.keys("chat:u1:")
assert set(keys) == {"chat:u1:C1", "chat:u1:C2"}
async def test_sqlite_set_and_get(tmp_path):
store = SQLiteStore(str(tmp_path / "test.db"))
await store.set("k", {"hello": "world"})
assert await store.get("k") == {"hello": "world"}
async def test_sqlite_overwrite(tmp_path):
store = SQLiteStore(str(tmp_path / "test.db"))
await store.set("k", {"v": 1})
await store.set("k", {"v": 2})
assert await store.get("k") == {"v": 2}
async def test_sqlite_delete(tmp_path):
store = SQLiteStore(str(tmp_path / "test.db"))
await store.set("k", {"v": 1})
await store.delete("k")
assert await store.get("k") is None
async def test_sqlite_keys_prefix(tmp_path):
store = SQLiteStore(str(tmp_path / "test.db"))
await store.set("chat:u1:C1", {})
await store.set("chat:u1:C2", {})
await store.set("auth:u1", {})
keys = await store.keys("chat:u1:")
assert set(keys) == {"chat:u1:C1", "chat:u1:C2"}

View file

@ -0,0 +1,49 @@
# tests/core/test_voice_slot.py
import pytest
from core.protocol import IncomingMessage, Attachment, OutgoingMessage
from core.handlers.message import handle_message
from core.store import InMemoryStore
from core.auth import AuthManager
from core.chat import ChatManager
from core.settings import SettingsManager
from platform.mock import MockPlatformClient
@pytest.fixture
def deps():
platform = MockPlatformClient()
store = InMemoryStore()
auth_mgr = AuthManager(platform, store)
return dict(
platform=platform,
chat_mgr=ChatManager(platform, store),
auth_mgr=auth_mgr,
settings_mgr=SettingsManager(platform, store),
)
async def test_voice_message_returns_stub(deps):
await deps["auth_mgr"].confirm("u1")
msg = IncomingMessage(
user_id="u1", platform="telegram", chat_id="C1", text="",
attachments=[Attachment(type="audio", filename="voice.ogg")],
)
result = await handle_message(event=msg, **deps)
assert len(result) == 1
assert isinstance(result[0], OutgoingMessage)
assert "голосов" in result[0].text.lower()
async def test_text_message_calls_platform(deps):
await deps["auth_mgr"].confirm("u1")
msg = IncomingMessage(user_id="u1", platform="telegram", chat_id="C1", text="Привет!")
result = await handle_message(event=msg, **deps)
texts = [r.text for r in result if isinstance(r, OutgoingMessage)]
assert any("[MOCK]" in t for t in texts)
async def test_unauthenticated_user_gets_start_prompt(deps):
msg = IncomingMessage(user_id="new_user", platform="telegram", chat_id="C1", text="hello")
result = await handle_message(event=msg, **deps)
assert len(result) == 1
assert "/start" in result[0].text

View file

@ -0,0 +1,45 @@
# tests/platform/test_mock.py
from platform.mock import MockPlatformClient
from platform.interface import User, MessageResponse, UserSettings
from core.protocol import SettingsAction
async def test_get_or_create_user_returns_user():
client = MockPlatformClient()
user = await client.get_or_create_user("12345", "telegram", "Иван")
assert isinstance(user, User)
assert user.external_id == "12345"
assert user.platform == "telegram"
assert user.is_new is True
async def test_get_or_create_user_idempotent():
client = MockPlatformClient()
u1 = await client.get_or_create_user("42", "matrix")
u2 = await client.get_or_create_user("42", "matrix")
assert u1.user_id == u2.user_id
assert u2.is_new is False
async def test_send_message_returns_response():
client = MockPlatformClient()
user = await client.get_or_create_user("u1", "telegram")
result = await client.send_message(user.user_id, "C1", "Привет!")
assert isinstance(result, MessageResponse)
assert result.finished is True
assert len(result.response) > 0
async def test_get_settings_returns_defaults():
client = MockPlatformClient()
settings = await client.get_settings("usr-telegram-42")
assert isinstance(settings, UserSettings)
assert "web-search" in settings.skills
async def test_update_settings_toggle_skill():
client = MockPlatformClient()
action = SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True})
await client.update_settings("usr-1", action)
settings = await client.get_settings("usr-1")
assert settings.skills.get("browser") is True