366 lines
13 KiB
Python
366 lines
13 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from pydantic import ValidationError
|
||
|
|
|
||
|
|
from slack_mock.models import SlackState
|
||
|
|
from slack_mock.server import (
|
||
|
|
create_channel,
|
||
|
|
delete_message,
|
||
|
|
open_dm,
|
||
|
|
post_message,
|
||
|
|
reply_to_thread,
|
||
|
|
search_messages,
|
||
|
|
send_dm,
|
||
|
|
set_user_status,
|
||
|
|
)
|
||
|
|
from slack_mock.state import get_bot_user_id, get_state, mutate_state, reset_state, state_from_json
|
||
|
|
|
||
|
|
|
||
|
|
def _seed_state() -> None:
|
||
|
|
state_from_json(
|
||
|
|
{
|
||
|
|
"bot_user_id": "U_MOCK_BOT",
|
||
|
|
"users": {
|
||
|
|
"U_MOCK_BOT": {
|
||
|
|
"id": "U_MOCK_BOT",
|
||
|
|
"name": "slackbot",
|
||
|
|
"profile": {"display_name": "Mock Bot"},
|
||
|
|
"is_bot": True,
|
||
|
|
},
|
||
|
|
"U001": {
|
||
|
|
"id": "U001",
|
||
|
|
"name": "alice",
|
||
|
|
"real_name": "Alice Example",
|
||
|
|
"profile": {"display_name": "Alice", "email": "alice@example.com"},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
"channels": {
|
||
|
|
"C001": {
|
||
|
|
"id": "C001",
|
||
|
|
"name": "general",
|
||
|
|
"context_team_id": "T_MOCK",
|
||
|
|
}
|
||
|
|
},
|
||
|
|
"messages": {
|
||
|
|
"C001": [
|
||
|
|
{
|
||
|
|
"type": "message",
|
||
|
|
"user": "U001",
|
||
|
|
"text": "March hours are ready",
|
||
|
|
"ts": "1700000001.000",
|
||
|
|
"team": "T_MOCK",
|
||
|
|
"reactions": [{"name": "eyes", "users": ["U_MOCK_BOT"], "count": 1}],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"type": "message",
|
||
|
|
"user": "U_MOCK_BOT",
|
||
|
|
"text": "Budget link https://example.com",
|
||
|
|
"ts": "1700000002.000",
|
||
|
|
"team": "T_MOCK",
|
||
|
|
},
|
||
|
|
]
|
||
|
|
},
|
||
|
|
"counters": {"channelId": 1000, "fileId": 1000},
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(autouse=True)
|
||
|
|
def seeded_state() -> None:
|
||
|
|
_seed_state()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_create_channel_preserves_existing_name_normalization_behavior() -> None:
|
||
|
|
result = await create_channel("MyChannel")
|
||
|
|
|
||
|
|
assert result["ok"] is True
|
||
|
|
assert result["channel"]["name"] == "mychannel"
|
||
|
|
assert result["channel"]["name_normalized"] == "mychannel"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_search_messages_keeps_warning_based_limit_and_cursor_behavior() -> None:
|
||
|
|
result = await search_messages("budget", limit=999, cursor="not-a-number")
|
||
|
|
|
||
|
|
assert result["ok"] is True
|
||
|
|
assert result["messages"]["total"] == 1
|
||
|
|
assert result["response_metadata"]["warnings"] == [
|
||
|
|
"limit exceeds the maximum of 100; using 100.",
|
||
|
|
"Invalid cursor 'not-a-number'; using the first page.",
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_search_messages_keeps_empty_query_tool_error_shape() -> None:
|
||
|
|
result = await search_messages("")
|
||
|
|
|
||
|
|
assert result == {
|
||
|
|
"ok": False,
|
||
|
|
"error": "missing_query",
|
||
|
|
"messages": {"matches": [], "total": 0},
|
||
|
|
"response_metadata": {"warnings": []},
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_status_text_can_be_empty_to_clear_status() -> None:
|
||
|
|
result = await set_user_status("")
|
||
|
|
|
||
|
|
assert result["ok"] is True
|
||
|
|
assert result["profile"]["status_text"] == ""
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_open_and_send_dm_preserve_current_behavior() -> None:
|
||
|
|
opened = await open_dm("U001")
|
||
|
|
sent = await send_dm("U001", "hello")
|
||
|
|
|
||
|
|
assert opened["ok"] is True
|
||
|
|
assert opened["channel"]["is_im"] is True
|
||
|
|
assert sent["ok"] is True
|
||
|
|
assert sent["channel"] == opened["channel"]["id"]
|
||
|
|
assert get_state().messages[sent["channel"]][-1].text == "hello"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_default_state_can_write_bot_messages() -> None:
|
||
|
|
reset_state()
|
||
|
|
created = await create_channel("general")
|
||
|
|
assert created["ok"] is True
|
||
|
|
|
||
|
|
posted = await post_message(created["channel"]["id"], "hello from default state")
|
||
|
|
|
||
|
|
assert posted["ok"] is True
|
||
|
|
assert posted["message"]["user"] == "U_MOCK_BOT"
|
||
|
|
assert "U_MOCK_BOT" in get_state().users
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_thread_reply_metadata_updates_atomically() -> None:
|
||
|
|
first = await reply_to_thread("C001", "1700000001.000", "first reply")
|
||
|
|
second = await reply_to_thread("C001", "1700000001.000", "second reply")
|
||
|
|
|
||
|
|
assert first["ok"] is True
|
||
|
|
assert second["ok"] is True
|
||
|
|
parent = get_state().messages["C001"][0]
|
||
|
|
assert parent.reply_count == 2
|
||
|
|
assert parent.reply_users == ["U_MOCK_BOT"]
|
||
|
|
assert parent.reply_users_count == 1
|
||
|
|
assert parent.latest_reply == max(first["ts"], second["ts"], key=float)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_deleting_thread_reply_refreshes_parent_metadata() -> None:
|
||
|
|
first = await reply_to_thread("C001", "1700000001.000", "first reply")
|
||
|
|
second = await reply_to_thread("C001", "1700000001.000", "second reply")
|
||
|
|
|
||
|
|
deleted = await delete_message("C001", second["ts"])
|
||
|
|
|
||
|
|
assert deleted["ok"] is True
|
||
|
|
parent = get_state().messages["C001"][0]
|
||
|
|
assert parent.reply_count == 1
|
||
|
|
assert parent.reply_users == ["U_MOCK_BOT"]
|
||
|
|
assert parent.reply_users_count == 1
|
||
|
|
assert parent.latest_reply == first["ts"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_failed_state_mutation_rolls_back_live_state() -> None:
|
||
|
|
before = get_state().model_dump(mode="json", by_alias=True, exclude_none=True)
|
||
|
|
|
||
|
|
with pytest.raises(ValidationError, match="bot_user_id"):
|
||
|
|
mutate_state(lambda state: state.users.pop("U_MOCK_BOT"))
|
||
|
|
|
||
|
|
assert get_state().model_dump(mode="json", by_alias=True, exclude_none=True) == before
|
||
|
|
|
||
|
|
|
||
|
|
def test_bot_user_id_reads_current_state_after_direct_mutation() -> None:
|
||
|
|
mutate_state(lambda state: setattr(state, "bot_user_id", "U001"))
|
||
|
|
|
||
|
|
assert get_bot_user_id() == "U001"
|
||
|
|
|
||
|
|
|
||
|
|
def test_state_rejects_message_channel_without_matching_channel() -> None:
|
||
|
|
with pytest.raises(ValidationError, match="does not reference an existing channel"):
|
||
|
|
SlackState.model_validate(
|
||
|
|
{
|
||
|
|
"channels": {},
|
||
|
|
"messages": {"C404": [{"type": "message", "text": "orphan", "ts": "1700000001.000"}]},
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_state_rejects_reaction_count_drift() -> None:
|
||
|
|
with pytest.raises(ValidationError, match="reaction.count"):
|
||
|
|
SlackState.model_validate(
|
||
|
|
{
|
||
|
|
"channels": {"C001": {"id": "C001", "name": "general"}},
|
||
|
|
"messages": {
|
||
|
|
"C001": [
|
||
|
|
{
|
||
|
|
"type": "message",
|
||
|
|
"text": "bad reaction",
|
||
|
|
"ts": "1700000001.000",
|
||
|
|
"reactions": [{"name": "eyes", "users": ["U001"], "count": 2}],
|
||
|
|
}
|
||
|
|
]
|
||
|
|
},
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_state_rejects_unknown_extra_fields() -> None:
|
||
|
|
with pytest.raises(ValidationError, match="Extra inputs are not permitted"):
|
||
|
|
SlackState.model_validate({"channels": {}, "messages": {}, "users": {}, "unexpected": True})
|
||
|
|
|
||
|
|
|
||
|
|
def test_state_rejects_duplicate_usernames_but_allows_duplicate_display_names() -> None:
|
||
|
|
state = {
|
||
|
|
"users": {
|
||
|
|
"U001": {"id": "U001", "name": "alice", "profile": {"display_name": "Alex"}},
|
||
|
|
"U002": {"id": "U002", "name": "bob", "profile": {"display_name": "Alex"}},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
assert SlackState.model_validate(state).users["U002"].profile.display_name == "Alex"
|
||
|
|
|
||
|
|
state["users"]["U002"]["name"] = "Alice"
|
||
|
|
with pytest.raises(ValidationError, match="user name"):
|
||
|
|
SlackState.model_validate(state)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
("field", "value", "match"),
|
||
|
|
[
|
||
|
|
("user_id", "bad-user", "String should match pattern"),
|
||
|
|
("channel_id", "X001", "String should match pattern"),
|
||
|
|
("file_id", "BAD001", "String should match pattern"),
|
||
|
|
("timestamp", "1700000001", "String should match pattern"),
|
||
|
|
("status_emoji", "palm_tree", "String should match pattern"),
|
||
|
|
("message_type", "event", "Input should be"),
|
||
|
|
("message_subtype", "unknown_subtype", "Input should be"),
|
||
|
|
("file_mode", "mystery", "Input should be"),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
def test_state_rejects_bad_primitive_shapes(field: str, value: str, match: str) -> None:
|
||
|
|
state: dict[str, Any] = {
|
||
|
|
"users": {
|
||
|
|
"U001": {
|
||
|
|
"id": "U001",
|
||
|
|
"name": "alice",
|
||
|
|
"profile": {"status_emoji": ":palm_tree:"},
|
||
|
|
}
|
||
|
|
},
|
||
|
|
"channels": {"C001": {"id": "C001", "name": "general"}},
|
||
|
|
"messages": {
|
||
|
|
"C001": [
|
||
|
|
{
|
||
|
|
"type": "message",
|
||
|
|
"text": "hello",
|
||
|
|
"ts": "1700000001.000",
|
||
|
|
"user": "U001",
|
||
|
|
"files": [{"id": "F001", "mode": "hosted"}],
|
||
|
|
}
|
||
|
|
]
|
||
|
|
},
|
||
|
|
}
|
||
|
|
if field == "user_id":
|
||
|
|
state["users"] = {value: {"id": value, "name": "alice"}}
|
||
|
|
elif field == "channel_id":
|
||
|
|
state["channels"] = {value: {"id": value, "name": "general"}}
|
||
|
|
state["messages"] = {value: state["messages"]["C001"]}
|
||
|
|
elif field == "file_id":
|
||
|
|
state["messages"]["C001"][0]["files"][0]["id"] = value
|
||
|
|
elif field == "timestamp":
|
||
|
|
state["messages"]["C001"][0]["ts"] = value
|
||
|
|
elif field == "status_emoji":
|
||
|
|
state["users"]["U001"]["profile"]["status_emoji"] = value
|
||
|
|
elif field == "message_type":
|
||
|
|
state["messages"]["C001"][0]["type"] = value
|
||
|
|
elif field == "message_subtype":
|
||
|
|
state["messages"]["C001"][0]["subtype"] = value
|
||
|
|
elif field == "file_mode":
|
||
|
|
state["messages"]["C001"][0]["files"][0]["mode"] = value
|
||
|
|
|
||
|
|
with pytest.raises(ValidationError, match=match):
|
||
|
|
SlackState.model_validate(state)
|
||
|
|
|
||
|
|
|
||
|
|
def _valid_relationship_state() -> dict[str, Any]:
|
||
|
|
return {
|
||
|
|
"bot_user_id": "U_MOCK_BOT",
|
||
|
|
"users": {
|
||
|
|
"U_MOCK_BOT": {"id": "U_MOCK_BOT", "name": "bot"},
|
||
|
|
"U001": {"id": "U001", "name": "alice"},
|
||
|
|
"U002": {"id": "U002", "name": "bob"},
|
||
|
|
},
|
||
|
|
"channels": {"C001": {"id": "C001", "name": "general"}},
|
||
|
|
"messages": {
|
||
|
|
"C001": [
|
||
|
|
{
|
||
|
|
"type": "message",
|
||
|
|
"text": "parent",
|
||
|
|
"ts": "1700000001.000",
|
||
|
|
"user": "U001",
|
||
|
|
"reply_count": 1,
|
||
|
|
"reply_users": ["U002"],
|
||
|
|
"reply_users_count": 1,
|
||
|
|
"latest_reply": "1700000002.000",
|
||
|
|
"reactions": [{"name": "eyes", "users": ["U_MOCK_BOT"], "count": 1}],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"type": "message",
|
||
|
|
"text": "reply",
|
||
|
|
"ts": "1700000002.000",
|
||
|
|
"user": "U002",
|
||
|
|
"thread_ts": "1700000001.000",
|
||
|
|
"files": [{"id": "F001", "user": "U002", "channels": ["C001"]}],
|
||
|
|
},
|
||
|
|
]
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def test_state_accepts_consistent_relationships() -> None:
|
||
|
|
assert SlackState.model_validate(_valid_relationship_state()).messages["C001"][0].reply_count == 1
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
("mutate", "match"),
|
||
|
|
[
|
||
|
|
(lambda state: state.__setitem__("bot_user_id", "U404"), "bot_user_id"),
|
||
|
|
(lambda state: state["messages"]["C001"][0].__setitem__("user", "U404"), "does not reference an existing user"),
|
||
|
|
(lambda state: state["channels"]["C001"].__setitem__("creator", "U404"), "creator"),
|
||
|
|
(
|
||
|
|
lambda state: state["channels"]["C001"].__setitem__(
|
||
|
|
"topic", {"value": "general", "creator": "U404", "last_set": 1}
|
||
|
|
),
|
||
|
|
"topic.creator",
|
||
|
|
),
|
||
|
|
(lambda state: state["messages"]["C001"][0]["reactions"][0]["users"].append("U404"), "reaction user"),
|
||
|
|
(lambda state: state["messages"]["C001"][1]["files"][0].__setitem__("channels", ["C404"]), "file channel"),
|
||
|
|
(lambda state: state["messages"]["C001"][0].__setitem__("reply_count", 2), "reply_count"),
|
||
|
|
(lambda state: state["messages"]["C001"][0].__setitem__("latest_reply", "1700000003.000"), "latest_reply"),
|
||
|
|
(
|
||
|
|
lambda state: state["messages"]["C001"][0].update(
|
||
|
|
{"pinned_to": ["C001"], "pinned_info": {"C001": {"pinned_by": "U404", "pinned_ts": 1}}}
|
||
|
|
),
|
||
|
|
"pinned_by",
|
||
|
|
),
|
||
|
|
(
|
||
|
|
lambda state: state["messages"]["C001"][0].update(
|
||
|
|
{"pinned_to": ["C001"], "pinned_info": {"C404": {"pinned_by": "U001", "pinned_ts": 1}}}
|
||
|
|
),
|
||
|
|
"pinned_info contains channels",
|
||
|
|
),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
def test_state_rejects_inconsistent_relationships(mutate, match: str) -> None:
|
||
|
|
state = _valid_relationship_state()
|
||
|
|
mutate(state)
|
||
|
|
|
||
|
|
with pytest.raises(ValidationError, match=match):
|
||
|
|
SlackState.model_validate(state)
|