Files
agent-desktop/tests/unit/app/chats/test_manager.py
T

395 lines
11 KiB
Python

# -*- coding: utf-8 -*-
"""Unit tests for ``pineagents.app.chats.manager.ChatManager``.
Uses the real :class:`JsonChatRepository` backed by ``tmp_path`` so the
tests cover the integrated CRUD path without mocking the repo away.
"""
# pylint: disable=protected-access,redefined-outer-name,unused-argument
from __future__ import annotations
import asyncio
from pathlib import Path
from unittest.mock import patch
import pytest
from pineagents.app.chats.manager import ChatManager
from pineagents.app.chats.models import (
ChatSpec,
ChatUpdate,
SessionSource,
)
from pineagents.app.chats.repo import (
JsonChatRepository,
)
from pineagents.app.channels.schema import DEFAULT_CHANNEL
@pytest.fixture
def repo_path(tmp_path: Path) -> Path:
return tmp_path / "chats.json"
@pytest.fixture
def manager(repo_path: Path) -> ChatManager:
return ChatManager(repo=JsonChatRepository(repo_path))
def _make_spec(
*,
chat_id: str | None = None,
session_id: str = "console:u1",
user_id: str = "u1",
name: str = "New Chat",
source: SessionSource = SessionSource.chat,
) -> ChatSpec:
kwargs = {
"session_id": session_id,
"user_id": user_id,
"name": name,
"source": source,
}
if chat_id is not None:
kwargs["id"] = chat_id
return ChatSpec(**kwargs)
# ---------------------------------------------------------------------------
# create / get / list
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_chat_returns_none_for_missing(manager: ChatManager):
assert await manager.get_chat("does-not-exist") is None
@pytest.mark.asyncio
async def test_create_and_get_chat_round_trip(manager: ChatManager):
spec = _make_spec(name="Hello")
created = await manager.create_chat(spec)
assert created.id == spec.id
fetched = await manager.get_chat(spec.id)
assert fetched is not None
assert fetched.name == "Hello"
assert fetched.session_id == "console:u1"
@pytest.mark.asyncio
async def test_list_chats_filters_by_user_and_channel(manager: ChatManager):
await manager.create_chat(
_make_spec(session_id="console:alice", user_id="alice"),
)
await manager.create_chat(
_make_spec(session_id="console:bob", user_id="bob"),
)
await manager.create_chat(
_make_spec(session_id="discord:alice", user_id="alice"),
)
# Patch the discord chat onto a different channel.
discord = (await manager.list_chats(user_id="alice"))[-1]
discord.channel = "discord"
await manager._repo.upsert_chat(discord)
alice_all = await manager.list_chats(user_id="alice")
alice_console = await manager.list_chats(
user_id="alice",
channel=DEFAULT_CHANNEL,
)
assert {c.user_id for c in alice_all} == {"alice"}
assert len(alice_all) == 2
assert all(c.channel == DEFAULT_CHANNEL for c in alice_console)
assert len(alice_console) == 1
@pytest.mark.asyncio
async def test_count_chats(manager: ChatManager):
for i in range(3):
await manager.create_chat(_make_spec(session_id=f"s{i}"))
assert await manager.count_chats() == 3
# ---------------------------------------------------------------------------
# get_or_create_chat
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_or_create_chat_creates_when_missing(manager: ChatManager):
spec = await manager.get_or_create_chat(
session_id="console:new",
user_id="new-user",
name="auto-registered",
)
assert spec.session_id == "console:new"
assert spec.user_id == "new-user"
assert spec.name == "auto-registered"
assert spec.source == SessionSource.chat
# Sanity: a follow-up call returns the SAME spec (idempotent).
again = await manager.get_or_create_chat(
session_id="console:new",
user_id="new-user",
)
assert again.id == spec.id
@pytest.mark.asyncio
async def test_get_or_create_chat_invalid_source_falls_back_to_chat(
manager: ChatManager,
):
spec = await manager.get_or_create_chat(
session_id="console:x",
user_id="u",
source="totally-bogus",
)
assert spec.source == SessionSource.chat
# ---------------------------------------------------------------------------
# patch_chat / patch_chat_if_name_matches
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_patch_chat_merges_partial_updates(manager: ChatManager):
spec = await manager.create_chat(_make_spec(name="before"))
before_updated = spec.updated_at
patched = await manager.patch_chat(
spec.id,
ChatUpdate(name="after", pinned=True),
)
assert patched is not None
assert patched.name == "after"
assert patched.pinned is True
# patch_chat refreshes updated_at.
assert patched.updated_at >= before_updated
@pytest.mark.asyncio
async def test_patch_chat_missing_returns_none(manager: ChatManager):
result = await manager.patch_chat("ghost", ChatUpdate(name="x"))
assert result is None
@pytest.mark.asyncio
async def test_patch_chat_if_name_matches_applies_when_name_matches(
manager: ChatManager,
):
spec = await manager.create_chat(_make_spec(name="Old Title"))
updated = await manager.patch_chat_if_name_matches(
spec.id,
expected_name="Old Title",
patch=ChatUpdate(name="Auto Title"),
)
assert updated is not None
assert updated.name == "Auto Title"
@pytest.mark.asyncio
async def test_patch_chat_if_name_matches_skips_on_mismatch(
manager: ChatManager,
):
# Simulate the race the CAS helper exists to prevent: user renamed
# the chat between read and write, so background title generation
# must NOT overwrite the new name.
spec = await manager.create_chat(_make_spec(name="User Chosen"))
result = await manager.patch_chat_if_name_matches(
spec.id,
expected_name="Old Default", # stale expectation
patch=ChatUpdate(name="Bogus Auto Title"),
)
assert result is None
refreshed = await manager.get_chat(spec.id)
assert refreshed.name == "User Chosen"
@pytest.mark.asyncio
async def test_patch_chat_if_name_matches_missing_returns_none(
manager: ChatManager,
):
result = await manager.patch_chat_if_name_matches(
"ghost",
expected_name="x",
patch=ChatUpdate(name="y"),
)
assert result is None
@pytest.mark.asyncio
async def test_touch_chat_refreshes_updated_at(manager: ChatManager):
spec = await manager.create_chat(_make_spec())
before = spec.updated_at
touched = await manager.touch_chat(spec.id)
assert touched is not None
assert touched.updated_at >= before
# ---------------------------------------------------------------------------
# delete
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delete_chats_returns_true_when_existing(manager: ChatManager):
spec_a = await manager.create_chat(_make_spec(session_id="a"))
spec_b = await manager.create_chat(_make_spec(session_id="b"))
assert await manager.delete_chats([spec_a.id]) is True
remaining = await manager.list_chats()
assert [c.id for c in remaining] == [spec_b.id]
@pytest.mark.asyncio
async def test_delete_chats_returns_false_when_missing(manager: ChatManager):
assert await manager.delete_chats(["nope"]) is False
# ---------------------------------------------------------------------------
# get_chat_id_by_session
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_chat_id_by_session_returns_none_when_no_match(
manager: ChatManager,
):
assert (
await manager.get_chat_id_by_session("missing", DEFAULT_CHANNEL)
is None
)
@pytest.mark.asyncio
async def test_get_chat_id_by_session_returns_most_recent_match(
manager: ChatManager,
):
old = await manager.create_chat(
_make_spec(session_id="console:dup", name="old"),
)
new = await manager.create_chat(
_make_spec(session_id="console:dup", name="new"),
)
# Force ``new`` to be the most recent.
await manager.patch_chat(new.id, ChatUpdate(name="new+1"))
chat_id = await manager.get_chat_id_by_session(
"console:dup",
DEFAULT_CHANNEL,
)
assert chat_id == new.id
assert chat_id != old.id
@pytest.mark.asyncio
async def test_touch_chat_by_session_uses_one_load_and_one_save(
manager: ChatManager,
):
old = await manager.create_chat(
_make_spec(session_id="console:dup", name="old"),
)
latest = await manager.create_chat(
_make_spec(session_id="console:dup", name="latest"),
)
latest = await manager.patch_chat(
latest.id,
ChatUpdate(name="latest+1"),
)
assert latest is not None
before = latest.updated_at
with (
patch.object(
manager._repo,
"load",
wraps=manager._repo.load,
) as load,
patch.object(
manager._repo,
"save",
wraps=manager._repo.save,
) as save,
):
touched = await manager.touch_chat_by_session(
"console:dup",
DEFAULT_CHANNEL,
)
assert touched is not None
assert touched.id == latest.id
assert touched.id != old.id
assert touched.updated_at >= before
load.assert_awaited_once()
save.assert_awaited_once()
@pytest.mark.asyncio
async def test_touch_chat_by_session_empty_user_disables_filter(
manager: ChatManager,
):
"""An empty user keeps the established no-filter lookup behavior."""
await manager.create_chat(
_make_spec(
session_id="console:shared",
user_id="u1",
name="older",
),
)
latest = await manager.create_chat(
_make_spec(
session_id="console:shared",
user_id="u2",
name="latest",
),
)
latest = await manager.patch_chat(
latest.id,
ChatUpdate(name="latest+1"),
)
assert latest is not None
touched = await manager.touch_chat_by_session(
"console:shared",
DEFAULT_CHANNEL,
user_id="",
)
assert touched is not None
assert touched.id == latest.id
# ---------------------------------------------------------------------------
# Lock serializes concurrent writes.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_concurrent_writes_are_serialized(manager: ChatManager):
# If the lock works, all 10 concurrent creates land without losing
# any spec on disk. The bug it prevents is two writes loading the
# same file snapshot in parallel and clobbering each other.
specs = [_make_spec(session_id=f"sess-{i}") for i in range(10)]
await asyncio.gather(*(manager.create_chat(s) for s in specs))
all_ids = {c.id for c in await manager.list_chats()}
assert all_ids == {s.id for s in specs}