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:
parent
944c383552
commit
36730ae716
27 changed files with 1315 additions and 3 deletions
38
tests/core/test_auth.py
Normal file
38
tests/core/test_auth.py
Normal 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
53
tests/core/test_chat.py
Normal 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
|
||||
85
tests/core/test_dispatcher.py
Normal file
85
tests/core/test_dispatcher.py
Normal 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"
|
||||
85
tests/core/test_integration.py
Normal file
85
tests/core/test_integration.py
Normal 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))
|
||||
42
tests/core/test_protocol.py
Normal file
42
tests/core/test_protocol.py
Normal 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"
|
||||
32
tests/core/test_settings.py
Normal file
32
tests/core/test_settings.py
Normal 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
58
tests/core/test_store.py
Normal 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"}
|
||||
49
tests/core/test_voice_slot.py
Normal file
49
tests/core/test_voice_slot.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue