Add Handbook.md benchmark tasks

This commit is contained in:
DerekSurge
2026-06-24 12:44:34 -07:00
commit 25c9eda5ca
1800 changed files with 323575 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from slack_mock.state import set_snapshot_paths
sys.path.insert(0, str(Path(__file__).parent))
@pytest.fixture(autouse=True)
def clean_env(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("INPUTDIR", raising=False)
monkeypatch.delenv("OUTPUTDIR", raising=False)
monkeypatch.delenv("BUNDLEDIR", raising=False)
monkeypatch.delenv("BUNDLE_OUTPUT_DIR", raising=False)
monkeypatch.delenv("MCP_PROXY_TOKEN", raising=False)
set_snapshot_paths(final_path=None, bundle_state_path=None)
yield
set_snapshot_paths(final_path=None, bundle_state_path=None)
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
from typing import Any
from slack_mock.state import state_from_json
def seed_state(data: dict[str, Any]) -> None:
defaults = {
"bot_user_id": "U_MOCK_BOT",
"users": {
"U_MOCK_BOT": {
"id": "U_MOCK_BOT",
"name": "bot",
"real_name": "Mock Bot",
"profile": {"display_name": "bot", "status_text": "", "status_emoji": ""},
"deleted": False,
}
},
"channels": {},
"messages": {},
"counters": {"channelId": 1000, "fileId": 1000},
}
merged = {**defaults, **data}
merged["users"] = {**defaults["users"], **data.get("users", {})}
merged["counters"] = {**defaults["counters"], **data.get("counters", {})}
state_from_json(merged)
@@ -0,0 +1,159 @@
from __future__ import annotations
import pytest
from helpers import seed_state
from slack_mock.server import archive_channel, create_channel, list_channels, rename_channel, set_channel_topic
from slack_mock.state import get_state
def setup_function() -> None:
seed_state(
{
"users": {"U001": {"id": "U001", "name": "alice"}},
"channels": {
"C001": {
"id": "C001",
"name": "general",
"name_normalized": "general",
"is_general": True,
"context_team_id": "T_MOCK",
"updated": 1700000000,
"creator": "U001",
"num_members": 5,
"previous_names": [],
"topic": {"value": "General chat", "creator": "U001", "last_set": 1700000000},
"purpose": {"value": "Team comms", "creator": "U001", "last_set": 1700000000},
},
"C002": {
"id": "C002",
"name": "random",
"name_normalized": "random",
"is_archived": True,
"context_team_id": "T_MOCK",
"updated": 1700000000,
"creator": "U001",
"num_members": 3,
"previous_names": [],
"topic": {"value": "", "creator": "", "last_set": 0},
"purpose": {"value": "", "creator": "", "last_set": 0},
},
"C003": {
"id": "C003",
"name": "external",
"context_team_id": "T_MOCK",
"is_member": False,
},
"D001": {
"id": "D001",
"name": "alice",
"context_team_id": "T_MOCK",
"is_channel": False,
"is_im": True,
"is_private": True,
"user": "U001",
},
"G001": {
"id": "G001",
"name": "private",
"context_team_id": "T_MOCK",
"is_channel": False,
"is_group": True,
"is_private": True,
},
},
"messages": {"C001": [], "C002": [], "C003": [], "D001": [], "G001": []},
"counters": {"channelId": 1000},
}
)
@pytest.mark.asyncio
async def test_list_channels_filters_to_active_channel_memberships() -> None:
listed = await list_channels()
assert listed["ok"] is True
assert [channel["id"] for channel in listed["channels"]] == ["C001", "G001"]
@pytest.mark.asyncio
async def test_create_channel_public_private_and_messages() -> None:
public = await create_channel("engineering")
private = await create_channel("secret", is_private=True)
assert public["ok"] is True
assert public["channel"]["name"] == "engineering"
assert public["channel"]["is_private"] is False
assert public["channel"]["is_channel"] is True
assert get_state().messages[public["channel"]["id"]] == []
assert private["ok"] is True
assert private["channel"]["is_private"] is True
assert private["channel"]["is_group"] is True
assert private["channel"]["is_channel"] is False
@pytest.mark.asyncio
async def test_create_channel_normalizes_and_rejects_bad_names() -> None:
normalized = await create_channel("MyChannel")
duplicate = await create_channel("general")
empty = await create_channel(" ")
second = await create_channel("chan-b")
assert normalized["channel"]["name"] == "mychannel"
assert normalized["channel"]["name_normalized"] == "mychannel"
assert duplicate["ok"] is False
assert duplicate["error"] == "name_taken"
assert empty["ok"] is False
assert empty["error"] == "invalid_name"
assert normalized["channel"]["id"] != second["channel"]["id"]
@pytest.mark.asyncio
async def test_archive_channel() -> None:
before = get_state().channels["C001"].updated
archived = await archive_channel("C001")
already = await archive_channel("C002")
missing = await archive_channel("INVALID")
assert archived["ok"] is True
assert get_state().channels["C001"].is_archived is True
assert get_state().channels["C001"].updated >= before
assert already == {"ok": False, "error": "already_archived"}
assert missing == {"ok": False, "error": "channel_not_found"}
@pytest.mark.asyncio
async def test_rename_channel() -> None:
renamed = await rename_channel("C001", "announcements")
normalized = await rename_channel("C001", "NewName")
duplicate = await rename_channel("C001", "random")
missing = await rename_channel("INVALID", "test")
empty = await rename_channel("C001", " ")
assert renamed["ok"] is True
assert renamed["channel"]["name"] == "announcements"
previous_names = get_state().channels["C001"].previous_names
assert previous_names is not None
assert "general" in previous_names
assert normalized["channel"]["name"] == "newname"
assert duplicate["error"] == "name_taken"
assert missing["error"] == "channel_not_found"
assert empty["error"] == "invalid_name"
@pytest.mark.asyncio
async def test_set_channel_topic_and_purpose() -> None:
topic = await set_channel_topic("C001", topic="New topic")
assert topic["ok"] is True
assert topic["channel"]["topic"]["value"] == "New topic"
assert topic["channel"]["topic"]["creator"] == "U_MOCK_BOT"
assert topic["channel"]["purpose"]["value"] == "Team comms"
purpose = await set_channel_topic("C001", purpose="New purpose")
assert purpose["channel"]["purpose"]["value"] == "New purpose"
both = await set_channel_topic("C001", topic="T", purpose="P")
assert both["channel"]["topic"]["value"] == "T"
assert both["channel"]["purpose"]["value"] == "P"
assert (await set_channel_topic("INVALID", topic="test"))["error"] == "channel_not_found"
@@ -0,0 +1,187 @@
from __future__ import annotations
import pytest
from helpers import seed_state
from slack_mock.models import SlackChannel
from slack_mock.server import (
archive_channel,
create_channel,
list_dms,
open_dm,
open_mpim,
rename_channel,
send_dm,
send_mpim,
)
from slack_mock.state import get_state
def setup_function() -> None:
seed_state(
{
"channels": {"C001": {"id": "C001", "name": "general", "is_im": False, "context_team_id": "T_MOCK"}},
"messages": {"C001": []},
"users": {
"U001": {
"id": "U001",
"name": "alice",
"real_name": "Alice Smith",
"profile": {"display_name": "alice"},
},
"U002": {"id": "U002", "name": "bob", "real_name": "Bob Jones", "profile": {"display_name": "bob"}},
"U003": {
"id": "U003",
"name": "charlie",
"real_name": "Charlie Day",
"profile": {"display_name": "charlie"},
},
},
}
)
@pytest.mark.asyncio
async def test_open_dm() -> None:
result = await open_dm("U001")
assert result["ok"] is True
assert result["channel"]["is_im"] is True
assert result["channel"]["user"] == "U001"
assert result["channel"]["is_private"] is True
assert result["channel"]["is_channel"] is False
assert get_state().messages[result["channel"]["id"]] == []
@pytest.mark.asyncio
async def test_open_dm_reuses_existing_and_separates_users() -> None:
first = await open_dm("U001")
second = await open_dm("U001")
other = await open_dm("U002")
assert second["channel"]["id"] == first["channel"]["id"]
assert other["channel"]["id"] != first["channel"]["id"]
assert (await open_dm("NONEXISTENT"))["error"] == "user_not_found"
@pytest.mark.asyncio
async def test_list_dms() -> None:
assert (await list_dms())["channels"] == []
await open_dm("U001")
await open_dm("U002")
result = await list_dms()
assert len(result["channels"]) == 2
assert all(channel["is_im"] for channel in result["channels"])
assert all(channel["id"] != "C001" for channel in result["channels"])
assert len((await list_dms(limit=1))["channels"]) == 1
@pytest.mark.asyncio
async def test_direct_conversations_cannot_be_archived_or_renamed() -> None:
dm_id = (await open_dm("U001"))["channel"]["id"]
state = get_state()
state.channels["GMP001"] = SlackChannel.model_validate(
{
"id": "GMP001",
"name": "mpdm-alice--bob-1",
"is_channel": False,
"is_group": False,
"is_im": False,
"is_mpim": True,
"is_private": True,
"context_team_id": "T_MOCK",
}
)
state.messages["GMP001"] = []
for channel_id in (dm_id, "GMP001"):
archived = await archive_channel(channel_id)
renamed = await rename_channel(channel_id, "new-name")
assert archived == {"ok": False, "error": "method_not_supported_for_channel_type"}
assert renamed == {"ok": False, "error": "method_not_supported_for_channel_type", "channel": {}}
assert state.channels[dm_id].is_archived is False
assert state.channels[dm_id].name == "alice"
assert state.channels["GMP001"].is_archived is False
assert state.channels["GMP001"].name == "mpdm-alice--bob-1"
@pytest.mark.asyncio
async def test_list_dms_includes_multi_party_direct_conversations() -> None:
state = get_state()
state.channels["GMP001"] = SlackChannel.model_validate(
{
"id": "GMP001",
"name": "mpdm-alice--bob-1",
"is_channel": False,
"is_group": False,
"is_im": False,
"is_mpim": True,
"is_private": True,
"context_team_id": "T_MOCK",
}
)
state.messages["GMP001"] = []
result = await list_dms()
assert [channel["id"] for channel in result["channels"]] == ["GMP001"]
assert result["channels"][0]["is_mpim"] is True
@pytest.mark.asyncio
async def test_open_mpim_reuses_existing_conversation_by_members() -> None:
first = await open_mpim(["U002", "U001"])
second = await open_mpim(["U001", "U002"])
assert first["ok"] is True
assert first["channel"]["is_mpim"] is True
assert first["channel"]["is_private"] is True
assert first["channel"]["members"] == ["U_MOCK_BOT", "U001", "U002"]
assert second["channel"]["id"] == first["channel"]["id"]
assert [channel["id"] for channel in (await list_dms())["channels"]] == [first["channel"]["id"]]
@pytest.mark.asyncio
async def test_open_mpim_validates_user_list() -> None:
assert await open_mpim(["U001"]) == {"ok": False, "error": "not_enough_users", "channel": {}}
assert await open_mpim(["U001", "U001"]) == {"ok": False, "error": "not_enough_users", "channel": {}}
assert await open_mpim(["U001", "U404"]) == {"ok": False, "error": "user_not_found", "channel": {}}
@pytest.mark.asyncio
async def test_send_mpim_posts_and_reuses_conversation() -> None:
sent = await send_mpim(["U001", "U002"], "Hello group")
second = await send_mpim(["U002", "U001"], "Second group note")
assert sent["ok"] is True
assert sent["message"]["text"] == "Hello group"
assert second["channel"] == sent["channel"]
assert [message.text for message in get_state().messages[sent["channel"]]] == ["Hello group", "Second group note"]
assert (await send_mpim(["U001", "U404"], "Hi"))["ok"] is False
@pytest.mark.asyncio
async def test_dm_names_do_not_reserve_channel_names() -> None:
opened = await open_dm("U001")
created = await create_channel(opened["channel"]["name"])
assert created["ok"] is True
assert created["channel"]["name"] == "alice"
assert created["channel"]["is_im"] is False
@pytest.mark.asyncio
async def test_send_dm() -> None:
result = await send_dm("U001", "Hello Alice!")
assert result["ok"] is True
assert result["message"]["text"] == "Hello Alice!"
assert result["channel"]
assert result["ts"]
assert len((await list_dms())["channels"]) == 1
await send_dm("U001", "Second")
assert len((await list_dms())["channels"]) == 1
assert get_state().messages[result["channel"]][0].text == "Hello Alice!"
assert (await send_dm("NONEXISTENT", "Hi"))["ok"] is False
@@ -0,0 +1,63 @@
from __future__ import annotations
import pytest
from helpers import seed_state
from slack_mock.server import list_files, upload_file
def setup_function() -> None:
seed_state(
{
"channels": {"C001": {"id": "C001", "name": "general", "context_team_id": "T_MOCK"}},
"messages": {"C001": []},
}
)
@pytest.mark.asyncio
async def test_upload_file_creates_message_and_detects_type() -> None:
result = await upload_file("C001", "report.pdf", "AAAA")
assert result["ok"] is True
assert result["file"]["name"] == "report.pdf"
assert result["file"]["mimetype"] == "application/pdf"
assert result["file"]["id"]
png = await upload_file("C001", "screenshot.png", "AA==")
csv = await upload_file("C001", "data.csv", "AA==")
assert png["file"]["mimetype"] == "image/png"
assert png["file"]["filetype"] == "png"
assert csv["file"]["mimetype"] == "text/csv"
@pytest.mark.asyncio
async def test_upload_file_title_comment_errors_and_size() -> None:
titled = await upload_file("C001", "q1.xlsx", "AA==", title="Q1 Financial Report")
assert titled["file"]["title"] == "Q1 Financial Report"
await upload_file("C001", "doc.pdf", "AA==", initial_comment="Here is the spec document")
files = await list_files("C001")
assert files["total"] == 2
missing = await upload_file("INVALID", "x.txt", "AA==")
assert missing["ok"] is False
assert missing["error"] == "channel_not_found"
sized = await upload_file("C001", "test.txt", "SGVsbG8gV29ybGQ=")
assert sized["file"]["size"] > 0
@pytest.mark.asyncio
async def test_list_files_empty_uploaded_limit_and_errors() -> None:
assert await list_files("C001") == {"ok": True, "files": [], "total": 0}
await upload_file("C001", "a.png", "AA==")
await upload_file("C001", "b.pdf", "AA==")
result = await list_files("C001")
assert result["total"] == 2
assert {file["name"] for file in result["files"]} == {"a.png", "b.pdf"}
await upload_file("C001", "c.png", "AA==")
limited = await list_files("C001", limit=2)
assert len(limited["files"]) == 2
assert limited["total"] == 3
assert (await list_files("INVALID"))["error"] == "channel_not_found"
@@ -0,0 +1,78 @@
from __future__ import annotations
import pytest
from helpers import seed_state
from slack_mock.server import delete_message, edit_message
from slack_mock.state import get_state
def setup_function() -> None:
seed_state(
{
"is_admin": True,
"users": {
"U001": {"id": "U001", "name": "alice"},
"U002": {"id": "U002", "name": "bob"},
},
"channels": {"C001": {"id": "C001", "name": "general", "context_team_id": "T_MOCK"}},
"messages": {
"C001": [
{"ts": "1700000001.000", "text": "Hello world", "user": "U001", "type": "message"},
{
"ts": "1700000002.000",
"text": "Reply in thread",
"user": "U002",
"type": "message",
"thread_ts": "1700000001.000",
},
{"ts": "1700000003.000", "text": "Another message", "user": "U001", "type": "message"},
]
},
}
)
@pytest.mark.asyncio
async def test_edit_message() -> None:
result = await edit_message("C001", "1700000001.000", "Updated text")
assert result["ok"] is True
assert result["text"] == "Updated text"
assert result["message"]["text"] == "Updated text"
assert result["channel"] == "C001"
assert result["ts"] == "1700000001.000"
msg = get_state().messages["C001"][0]
assert msg.edited is not None
assert msg.edited.user == "U_MOCK_BOT"
assert msg.edited.ts
@pytest.mark.asyncio
async def test_edit_message_errors_and_thread_replies() -> None:
assert (await edit_message("INVALID", "1700000001.000", "x"))["error"] == "channel_not_found"
assert (await edit_message("C001", "9999999999.000", "x"))["error"] == "message_not_found"
reply = await edit_message("C001", "1700000002.000", "Edited reply")
assert reply["ok"] is True
assert reply["message"]["text"] == "Edited reply"
assert reply["message"]["thread_ts"] == "1700000001.000"
@pytest.mark.asyncio
async def test_delete_message() -> None:
assert len(get_state().messages["C001"]) == 3
result = await delete_message("C001", "1700000003.000")
assert result == {"ok": True, "channel": "C001", "ts": "1700000003.000"}
assert len(get_state().messages["C001"]) == 2
assert all(message.ts != "1700000003.000" for message in get_state().messages["C001"])
@pytest.mark.asyncio
async def test_delete_message_errors_and_thread_replies() -> None:
assert (await delete_message("INVALID", "1700000001.000"))["error"] == "channel_not_found"
assert (await delete_message("C001", "9999999999.000"))["error"] == "message_not_found"
result = await delete_message("C001", "1700000002.000")
assert result["ok"] is True
assert any(message.ts == "1700000001.000" for message in get_state().messages["C001"])
@@ -0,0 +1,106 @@
from __future__ import annotations
import pytest
from slack_mock.server import get_channel_history, list_workspaces, post_message, search_messages
from slack_mock.state import get_active_workspace_id, state_from_json, state_to_json
def _workspace_state(message_text: str) -> dict:
return {
"bot_user_id": "U_MOCK_BOT",
"users": {
"U_MOCK_BOT": {
"id": "U_MOCK_BOT",
"name": "bot",
"real_name": "Mock Bot",
"profile": {"display_name": "bot", "status_text": "", "status_emoji": ""},
"deleted": False,
}
},
"channels": {"C1": {"id": "C1", "name": "general", "context_team_id": "T_MOCK"}},
"messages": {"C1": [{"ts": "1700000001.000", "text": message_text, "user": "U_MOCK_BOT", "type": "message"}]},
}
@pytest.mark.asyncio
async def test_workspace_selector_routes_reads_and_writes_independently() -> None:
state_from_json(
{
"workspaces": {
"default": _workspace_state("default workspace"),
"acme": _workspace_state("acme workspace"),
}
}
)
listed = await list_workspaces()
assert listed["ok"] is True
assert listed["total"] == 2
assert {workspace["workspace_id"] for workspace in listed["workspaces"]} == {"default", "acme"}
default_history = await get_channel_history("C1")
acme_history = await get_channel_history("C1", workspace_id="acme")
assert default_history["messages"][0]["text"] == "default workspace"
assert acme_history["messages"][0]["text"] == "acme workspace"
posted = await post_message("C1", "acme follow-up", workspace_id="acme")
assert posted["ok"] is True
exported = state_to_json()
assert len(exported["workspaces"]["default"]["messages"]["C1"]) == 1
assert len(exported["workspaces"]["acme"]["messages"]["C1"]) == 2
assert (await search_messages("acme", workspace_id="acme"))["messages"]["total"] == 2
assert (await search_messages("acme", workspace_id="default"))["messages"]["total"] == 0
@pytest.mark.asyncio
async def test_failed_workspace_write_does_not_mutate_other_workspaces() -> None:
state_from_json(
{
"workspaces": {
"default": _workspace_state("default workspace"),
"acme": _workspace_state("acme workspace"),
}
}
)
before = state_to_json()
failed = await post_message("C_DOES_NOT_EXIST", "should not post", workspace_id="acme")
assert failed["ok"] is False
assert failed["error"] == "channel_not_found"
assert state_to_json() == before
@pytest.mark.asyncio
async def test_state_import_resets_active_workspace() -> None:
state_from_json(
{
"workspaces": {
"default": _workspace_state("default workspace"),
"acme": _workspace_state("acme workspace"),
}
}
)
await get_channel_history("C1", workspace_id="acme")
assert get_active_workspace_id() == "acme"
state_from_json({"workspaces": {"default": _workspace_state("reset default workspace")}})
assert get_active_workspace_id() == "default"
assert (await get_channel_history("C1"))["messages"][0]["text"] == "reset default workspace"
@pytest.mark.asyncio
async def test_omitted_workspace_uses_active_non_default_workspace() -> None:
state_from_json({"workspaces": {"acme": _workspace_state("acme only workspace")}})
assert get_active_workspace_id() == "acme"
assert (await get_channel_history("C1"))["messages"][0]["text"] == "acme only workspace"
posted = await post_message("C1", "acme follow-up")
assert posted["ok"] is True
assert state_to_json()["workspaces"]["acme"]["messages"]["C1"][-1]["text"] == "acme follow-up"
@@ -0,0 +1,162 @@
from __future__ import annotations
import pytest
from helpers import seed_state
from slack_mock.server import (
delete_message,
edit_message,
get_user_presence,
list_pins,
pin_message,
set_user_status,
unpin_message,
)
from slack_mock.state import get_state
def seed_pins_state() -> None:
seed_state(
{
"channels": {"C001": {"id": "C001", "name": "general", "context_team_id": "T_MOCK"}},
"messages": {
"C001": [
{"ts": "1700000001.000", "text": "Hello world", "user": "U001", "type": "message"},
{
"ts": "1700000002.000",
"text": "Pinned already",
"user": "U001",
"type": "message",
"pinned_to": ["C001"],
"pinned_info": {"C001": {"pinned_by": "U001", "pinned_ts": 1700000000}},
},
{"ts": "1700000003.000", "text": "Another message", "user": "U002", "type": "message"},
]
},
"users": {
"U001": {
"id": "U001",
"name": "alice",
"deleted": False,
"profile": {"display_name": "alice", "status_text": "", "status_emoji": ""},
},
"U002": {
"id": "U002",
"name": "bob",
"deleted": True,
"profile": {"display_name": "bob", "status_text": "", "status_emoji": ""},
},
},
}
)
@pytest.mark.asyncio
async def test_pin_unpin_and_list_pins() -> None:
seed_pins_state()
pinned = await pin_message("C001", "1700000001.000")
assert pinned["ok"] is True
msg = get_state().messages["C001"][0]
assert msg.pinned_to is not None
assert msg.pinned_info is not None
assert "C001" in msg.pinned_to
assert msg.pinned_info["C001"]["pinned_by"] == "U_MOCK_BOT"
assert (await pin_message("C001", "1700000002.000"))["error"] == "already_pinned"
assert (await pin_message("INVALID", "1700000001.000"))["error"] == "channel_not_found"
assert (await pin_message("C001", "9999999999.000"))["error"] == "message_not_found"
listed = await list_pins("C001")
assert listed["ok"] is True
assert len(listed["items"]) == 2
unpinned = await unpin_message("C001", "1700000002.000")
assert unpinned["ok"] is True
assert get_state().messages["C001"][1].pinned_to is None
assert (await unpin_message("C001", "1700000001.000"))["ok"] is True
assert (await unpin_message("C001", "1700000001.000"))["error"] == "not_pinned"
assert (await unpin_message("INVALID", "1700000002.000"))["error"] == "channel_not_found"
assert (await list_pins("INVALID"))["error"] == "channel_not_found"
@pytest.mark.asyncio
async def test_status_and_presence() -> None:
seed_pins_state()
default = await set_user_status("In a meeting")
assert default["ok"] is True
assert default["profile"]["status_text"] == "In a meeting"
assert get_state().users["U_MOCK_BOT"].profile.status_text == "In a meeting"
emoji = await set_user_status("Vacation", status_emoji=":palm_tree:")
assert emoji["profile"]["status_emoji"] == ":palm_tree:"
denied = await set_user_status("Busy", user_id="U001")
assert denied["ok"] is False
assert denied["error"] == "cant_update_profile"
assert get_state().users["U001"].profile.status_text == ""
assert (await set_user_status("test", user_id="INVALID"))["error"] == "user_not_found"
assert (await set_user_status(""))["profile"]["status_text"] == ""
active = await get_user_presence("U001")
away = await get_user_presence("U002")
missing = await get_user_presence("INVALID")
assert active["presence"] == "active"
assert active["online"] is True
assert active["manual_away"] is False
assert away["presence"] == "away"
assert away["online"] is False
assert away["manual_away"] is True
assert missing["error"] == "user_not_found"
@pytest.mark.asyncio
async def test_admin_can_set_other_user_status() -> None:
seed_pins_state()
get_state().is_admin = True
result = await set_user_status("Busy", user_id="U001")
assert result["ok"] is True
assert get_state().users["U001"].profile.status_text == "Busy"
def seed_permissions_state(is_admin: bool = False) -> None:
seed_state(
{
"is_admin": is_admin,
"channels": {"C001": {"id": "C001", "name": "general"}},
"messages": {
"C001": [
{"ts": "1700000001.000", "text": "Bot message", "user": "U_MOCK_BOT", "type": "message"},
{"ts": "1700000002.000", "text": "Someone else wrote this", "user": "U_OTHER", "type": "message"},
]
},
"users": {"U_OTHER": {"id": "U_OTHER", "name": "other"}},
}
)
@pytest.mark.asyncio
async def test_edit_delete_permissions() -> None:
seed_permissions_state(is_admin=False)
own_edit = await edit_message("C001", "1700000001.000", "Updated")
other_edit = await edit_message("C001", "1700000002.000", "Altered")
assert own_edit["ok"] is True
assert get_state().messages["C001"][0].text == "Updated"
assert other_edit["ok"] is False
assert other_edit["error"] == "cant_update_message"
assert get_state().messages["C001"][1].text == "Someone else wrote this"
own_delete = await delete_message("C001", "1700000001.000")
assert own_delete["ok"] is True
assert len(get_state().messages["C001"]) == 1
seed_permissions_state(is_admin=False)
other_delete = await delete_message("C001", "1700000002.000")
assert other_delete["ok"] is False
assert other_delete["error"] == "cant_delete_message"
assert len(get_state().messages["C001"]) == 2
seed_permissions_state(is_admin=True)
assert (await edit_message("C001", "1700000002.000", "Altered"))["ok"] is True
assert (await delete_message("C001", "1700000002.000"))["ok"] is True
assert len(get_state().messages["C001"]) == 1
@@ -0,0 +1,365 @@
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)
@@ -0,0 +1,238 @@
from __future__ import annotations
import pytest
from helpers import seed_state
from slack_mock.server import search_messages
def setup_function() -> None:
seed_state(
{
"channels": {
"C001": {"id": "C001", "name": "general", "is_private": False},
"C002": {"id": "C002", "name": "engineering", "is_private": False},
},
"messages": {
"C001": [
{
"ts": "1700000001.000",
"text": "Hello world",
"user": "U001",
"type": "message",
"reactions": [{"name": "wave", "users": ["U002"], "count": 1}],
"permalink": "https://example.com/message/C001/1700000001.000",
},
{
"ts": "1700000002.000",
"text": "Reply in thread",
"user": "U002",
"type": "message",
"thread_ts": "1700000001.000",
},
{
"ts": "1700000003.000",
"text": "Another message",
"user": "U001",
"type": "message",
"is_starred": True,
},
{"ts": "1700000004.000", "text": "Deployment is ready", "user": "U002", "type": "message"},
{
"ts": "1700000004.500",
"text": "Literal from:alice appears in this message",
"user": "U002",
"type": "message",
},
],
"C002": [
{"ts": "1700000005.000", "text": "Hello from engineering", "user": "U002", "type": "message"},
{
"ts": "1700000006.000",
"text": "Code review needed",
"user": "U001",
"type": "message",
"pinned_to": ["C002"],
},
{
"ts": "1700000007.000",
"text": "Design handoff link https://example.com/spec",
"user": "U003",
"type": "message",
},
{
"ts": "1700000008.000",
"text": "Multi marker message",
"user": "U001",
"type": "message",
"is_starred": True,
"reactions": [{"name": "eyes", "users": ["U002"], "count": 1}],
},
{
"ts": "1700000009.000",
"text": "Ambiguous display marker",
"user": "U004",
"type": "message",
},
],
},
"users": {
"U001": {
"id": "U001",
"name": "alice",
"real_name": "Alice Smith",
"profile": {"display_name": "alice"},
},
"U002": {"id": "U002", "name": "bob", "real_name": "Bob Jones", "profile": {"display_name": "bob"}},
"U003": {
"id": "U003",
"name": "alicia.smith",
"real_name": "Alicia Smith",
"profile": {"display_name": "alicia.smith"},
},
"U004": {
"id": "U004",
"name": "charlie",
"real_name": "Charles Example",
"profile": {"display_name": "bob"},
},
},
}
)
def texts(result: dict) -> list[str]:
return [match["text"] for match in result["messages"]["matches"]]
@pytest.mark.asyncio
async def test_text_matching_scoping_sorting_and_context() -> None:
result = await search_messages("Hello")
assert result["ok"] is True
assert result["messages"]["total"] == 2
assert all("hello" in match["text"].lower() for match in result["messages"]["matches"])
assert {match["channel"]["id"] for match in result["messages"]["matches"]} == {"C001", "C002"}
assert [float(match["ts"]) for match in result["messages"]["matches"]] == sorted(
[float(match["ts"]) for match in result["messages"]["matches"]], reverse=True
)
assert all(match["channel"]["name"] for match in result["messages"]["matches"])
assert all(match["user_name"] != "Unknown" for match in result["messages"]["matches"])
assert all("username" in match and "display_name" in match for match in result["messages"]["matches"])
scoped = await search_messages("Hello", channel_id="C001")
assert scoped["messages"]["total"] == 1
assert scoped["messages"]["matches"][0]["channel"]["id"] == "C001"
assert (await search_messages("Hello", channel_id="INVALID"))["error"] == "channel_not_found"
assert (await search_messages("zzzznonexistent"))["messages"]["matches"] == []
@pytest.mark.asyncio
async def test_user_and_thread_matching() -> None:
by_user_name = await search_messages("alice smith")
assert by_user_name["messages"]["total"] > 0
assert all(match["user"] == "U001" for match in by_user_name["messages"]["matches"])
reply = await search_messages("Reply in thread")
assert reply["messages"]["total"] == 1
assert reply["messages"]["matches"][0]["thread_ts"] == "1700000001.000"
@pytest.mark.asyncio
async def test_pagination_limits_and_cursors() -> None:
first = await search_messages("Hello", limit=1)
assert first["messages"]["total"] == 2
assert first["response_metadata"]["next_cursor"]
second = await search_messages("Hello", limit=1, cursor=first["response_metadata"]["next_cursor"])
assert second["messages"]["matches"][0]["ts"] != first["messages"]["matches"][0]["ts"]
capped = await search_messages("message", limit=999)
assert "limit exceeds the maximum of 100; using 100." in capped["response_metadata"]["warnings"]
malformed = await search_messages("hello", limit=1, cursor="not-a-number")
assert malformed["messages"]["matches"][0]["text"] == "Hello from engineering"
assert "Invalid cursor 'not-a-number'; using the first page." in malformed["response_metadata"]["warnings"]
@pytest.mark.asyncio
async def test_word_and_phrase_semantics() -> None:
assert (await search_messages("world hello"))["messages"]["matches"][0]["text"] == "Hello world"
assert (await search_messages("hello goodbye"))["messages"]["total"] == 0
assert (await search_messages('"hello world"'))["messages"]["matches"][0]["text"] == "Hello world"
assert (await search_messages('"world hello"'))["messages"]["total"] == 0
assert (await search_messages('needed "code review"'))["messages"]["matches"][0]["text"] == "Code review needed"
quoted = await search_messages('"from:alice"')
assert quoted["messages"]["matches"][0]["text"] == "Literal from:alice appears in this message"
assert quoted["response_metadata"]["warnings"] == []
mixed = await search_messages("deployment bob")
assert mixed["messages"]["matches"][0]["text"] == "Deployment is ready"
assert mixed["messages"]["matches"][0]["user"] == "U002"
@pytest.mark.asyncio
async def test_channel_and_user_filters() -> None:
assert (await search_messages("in:#engineering hello"))["messages"]["matches"][0]["channel"]["id"] == "C002"
assert (await search_messages("in:C001 hello"))["messages"]["matches"][0]["channel"]["id"] == "C001"
assert (await search_messages("in:#engineering hello", channel_id="C001"))["error"] == "channel_scope_conflict"
assert (await search_messages("in:#nonexistent hello"))["messages"]["total"] == 0
assert (await search_messages("in:#engineering hello", channel_id="INVALID"))["error"] == "channel_not_found"
assert (await search_messages("from:@bob deployment"))["messages"]["matches"][0]["user"] == "U002"
assert (await search_messages("from:U001 review"))["messages"]["matches"][0]["text"] == "Code review needed"
assert (await search_messages("from:alicia link"))["messages"]["matches"][0]["user"] == "U003"
assert {match["user"] for match in (await search_messages("from:@bob"))["messages"]["matches"]} == {"U002"}
display_name_match = (await search_messages("from:bob ambiguous"))["messages"]["matches"][0]
assert display_name_match["user"] == "U004"
assert display_name_match["username"] == "charlie"
assert display_name_match["display_name"] == "bob"
from_me = await search_messages("from:me hello")
assert from_me["messages"]["total"] == 0
assert (
"from:me is unsupported because caller identity is not available; it will match no users."
in from_me["response_metadata"]["warnings"]
)
@pytest.mark.asyncio
async def test_date_filters() -> None:
result = await search_messages("hello after:1700000002 before:1700000006")
assert result["messages"]["matches"][0]["text"] == "Hello from engineering"
assert (await search_messages("hello before:1700000005"))["messages"]["matches"][0]["text"] == "Hello world"
assert (await search_messages("during:2023-11-14 hello"))["messages"]["total"] == 2
assert (await search_messages("during:2023-11 hello"))["messages"]["total"] == 2
assert (await search_messages("during:2023 hello"))["messages"]["total"] == 2
invalid = await search_messages("during:2023-Q1 hello")
assert invalid["messages"]["total"] == 2
assert (
"Invalid during: date '2023-Q1'. Use YYYY, YYYY-MM, YYYY-MM-DD, or a parseable date."
in invalid["response_metadata"]["warnings"]
)
@pytest.mark.asyncio
async def test_has_filters_and_empty_filters() -> None:
unsupported = await search_messages("has:calendar hello")
assert unsupported["messages"]["total"] == 0
assert (
"Unsupported has: value 'calendar'. Supported values: link, reaction, star, pin."
in unsupported["response_metadata"]["warnings"]
)
assert (await search_messages("has:link"))["messages"]["matches"][0][
"text"
] == "Design handoff link https://example.com/spec"
assert (await search_messages('has:link "hello world"'))["messages"]["total"] == 0
assert set(texts(await search_messages("has:reaction"))) == {"Hello world", "Multi marker message"}
assert "Another message" in texts(await search_messages("has:star"))
assert (await search_messages("has:pin"))["messages"]["matches"][0]["text"] == "Code review needed"
assert (await search_messages("has:reaction has:star"))["messages"]["matches"][0]["text"] == "Multi marker message"
assert (await search_messages("has:reaction has:pin"))["messages"]["total"] == 0
with_text = await search_messages("from: hello")
assert with_text["messages"]["total"] == 2
assert "Empty from: filter was ignored." in with_text["response_metadata"]["warnings"]
assert (await search_messages("from:"))["error"] == "missing_query"
assert "Empty in: filter was ignored." in (await search_messages("in:"))["response_metadata"]["warnings"]
assert "Empty has: filter was ignored." in (await search_messages("has:"))["response_metadata"]["warnings"]
combined = await search_messages("in:#general from:@alice has:reaction after:2023-11-14 hello")
assert combined["messages"]["total"] == 1
assert combined["messages"]["matches"][0]["text"] == "Hello world"
@@ -0,0 +1,547 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from helpers import seed_state
from starlette.testclient import TestClient
from slack_mock import state as slack_state
from slack_mock.server import create_channel, upload_file
from slack_mock.state import (
delete_message,
dump_state,
generate_timestamp,
get_channel_messages,
get_state,
get_thread_replies,
load_state,
merge_inputdir_files,
resolve_bundle_output_path,
resolve_bundle_state_path,
resolve_bundle_state_paths,
resolve_input_paths,
set_snapshot_paths,
state_from_json,
state_to_json,
write_snapshots,
)
from slack_mock.viewer import create_slack_viewer_app
def test_resolve_input_paths(tmp_path: Path, monkeypatch) -> None:
(tmp_path / "b.json").write_text("{}", encoding="utf-8")
(tmp_path / "a.json").write_text("{}", encoding="utf-8")
(tmp_path / "readme.txt").write_text("hello", encoding="utf-8")
monkeypatch.setenv("INPUTDIR", str(tmp_path))
assert resolve_input_paths() == [tmp_path / "a.json", tmp_path / "b.json"]
monkeypatch.delenv("INPUTDIR")
assert resolve_input_paths() == []
def test_resolve_bundle_output_path(monkeypatch) -> None:
monkeypatch.setenv("BUNDLE_OUTPUT_DIR", "/some/output/services/slack")
assert resolve_bundle_output_path() == Path("/some/output/services/slack/state.json")
monkeypatch.delenv("BUNDLE_OUTPUT_DIR")
assert resolve_bundle_output_path() is None
def test_resolve_bundle_state_path_prefers_state_json(tmp_path: Path, monkeypatch) -> None:
"""state.json wins over a bare *.json sibling in the per-service subdir."""
service_dir = tmp_path / "services" / "slack"
service_dir.mkdir(parents=True)
(service_dir / "state.json").write_text("{}", encoding="utf-8")
(service_dir / "channels.json").write_text("{}", encoding="utf-8")
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
assert resolve_bundle_state_path() == service_dir / "state.json"
def test_resolve_bundle_state_path_globs_when_no_state_json(tmp_path: Path, monkeypatch) -> None:
"""The singular back-compat accessor returns the first *.json when there's
no state.json. (The loader itself reads the whole folder — see
resolve_bundle_state_paths.)"""
service_dir = tmp_path / "services" / "slack"
service_dir.mkdir(parents=True)
(service_dir / "b.json").write_text("{}", encoding="utf-8")
(service_dir / "a.json").write_text("{}", encoding="utf-8")
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
assert resolve_bundle_state_path() == service_dir / "a.json"
def test_resolve_bundle_state_path_missing_subdir(tmp_path: Path, monkeypatch) -> None:
"""A partial bundle without this service's subdir resolves to None so the
loader falls back to INPUTDIR."""
(tmp_path / "services").mkdir()
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
assert resolve_bundle_state_path() is None
monkeypatch.delenv("BUNDLEDIR")
assert resolve_bundle_state_path() is None
def test_resolve_bundle_state_paths_returns_whole_folder(tmp_path: Path, monkeypatch) -> None:
"""Without state.json the resolver returns ALL *.json (the folder is the
unit), not just the first — that's what the loader coalesces."""
service_dir = tmp_path / "services" / "slack"
service_dir.mkdir(parents=True)
(service_dir / "b.json").write_text("{}", encoding="utf-8")
(service_dir / "a.json").write_text("{}", encoding="utf-8")
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
assert resolve_bundle_state_paths() == [service_dir / "a.json", service_dir / "b.json"]
# state.json present → just the canonical single file.
(service_dir / "state.json").write_text("{}", encoding="utf-8")
assert resolve_bundle_state_paths() == [service_dir / "state.json"]
def _reset_slack_state() -> None:
slack_state._current_state = None
slack_state._workspaces.clear()
slack_state._active_workspace_id = "default"
def test_bundle_multifile_folder_matches_consolidated_state(tmp_path: Path, monkeypatch) -> None:
"""A folder of per-entity *.json (no state.json) loads to the SAME merged
state as the single consolidated file — no file is dropped."""
monkeypatch.delenv("INPUTDIR", raising=False)
monkeypatch.delenv("OUTPUTDIR", raising=False)
monkeypatch.delenv("BUNDLE_OUTPUT_DIR", raising=False)
user = {
"id": "U1",
"name": "alice",
"real_name": "Alice",
"profile": {"display_name": "alice", "status_text": "", "status_emoji": ""},
"is_bot": False,
"deleted": False,
}
channel = {"id": "C1", "name": "general", "context_team_id": "T_MOCK"}
# (a) One consolidated state.json.
consolidated_dir = tmp_path / "consolidated" / "services" / "slack"
consolidated_dir.mkdir(parents=True)
(consolidated_dir / "state.json").write_text(
json.dumps({"users": {"U1": user}, "channels": {"C1": channel}}), encoding="utf-8"
)
# (b) The same seed split across per-entity files (no state.json).
split_dir = tmp_path / "split" / "services" / "slack"
split_dir.mkdir(parents=True)
(split_dir / "users.json").write_text(json.dumps({"users": {"U1": user}}), encoding="utf-8")
(split_dir / "channels.json").write_text(json.dumps({"channels": {"C1": channel}}), encoding="utf-8")
monkeypatch.setenv("BUNDLEDIR", str(tmp_path / "consolidated"))
_reset_slack_state()
load_state()
consolidated = state_to_json()
monkeypatch.setenv("BUNDLEDIR", str(tmp_path / "split"))
_reset_slack_state()
load_state()
split = state_to_json()
assert split == consolidated
assert "U1" in split["users"]
assert "C1" in split["channels"]
def test_set_snapshot_paths_supports_partial_updates(tmp_path: Path) -> None:
seed_state({})
final = tmp_path / "final.json"
bundle = tmp_path / "services" / "slack" / "state.json"
set_snapshot_paths(final_path=final)
write_snapshots()
assert final.exists()
assert not bundle.exists()
set_snapshot_paths(bundle_state_path=bundle)
write_snapshots()
assert final.exists()
assert bundle.exists()
final.unlink()
set_snapshot_paths(final_path=None)
write_snapshots()
assert not final.exists()
assert bundle.exists()
def test_dump_state_writes_overwrites_and_creates_parent_dirs(tmp_path: Path) -> None:
seed_state({})
dest = tmp_path / "nested" / "dir" / "final.json"
dump_state(dest)
assert dest.exists()
snapshot = json.loads(dest.read_text(encoding="utf-8"))
assert {"users", "channels", "messages"}.issubset(snapshot)
first = dest.read_text(encoding="utf-8")
dump_state(dest)
assert dest.read_text(encoding="utf-8") == first
legacy = tmp_path / "final.json"
bundle = tmp_path / "services" / "slack" / "state.json"
dump_state(legacy)
dump_state(bundle)
assert legacy.read_text(encoding="utf-8") == bundle.read_text(encoding="utf-8")
def test_delete_message_cascades_thread_replies() -> None:
seed_state(
{
"channels": {"C1": {"id": "C1", "name": "general", "context_team_id": "T_MOCK"}},
"messages": {
"C1": [
{"ts": "1700000001.000", "text": "parent", "user": "U_MOCK_BOT", "type": "message"},
{
"ts": "1700000002.000",
"text": "reply",
"user": "U_MOCK_BOT",
"type": "message",
"thread_ts": "1700000001.000",
},
{"ts": "1700000003.000", "text": "unrelated", "user": "U_MOCK_BOT", "type": "message"},
]
},
}
)
assert delete_message("C1", "1700000001.000") is True
assert [message.ts for message in get_channel_messages("C1")] == ["1700000003.000"]
assert get_thread_replies("C1", "1700000001.000") == []
seed_state(
{
"channels": {"C1": {"id": "C1", "name": "general", "context_team_id": "T_MOCK"}},
"messages": {
"C1": [
{"ts": "1700000001.000", "text": "parent", "user": "U_MOCK_BOT", "type": "message"},
{
"ts": "1700000002.000",
"text": "reply",
"user": "U_MOCK_BOT",
"type": "message",
"thread_ts": "1700000001.000",
},
{"ts": "1700000003.000", "text": "unrelated", "user": "U_MOCK_BOT", "type": "message"},
]
},
}
)
assert delete_message("C1", "1700000003.000") is True
assert [message.ts for message in get_channel_messages("C1")] == ["1700000001.000", "1700000002.000"]
assert delete_message("C1", "1700000002.000") is True
assert [message.ts for message in get_channel_messages("C1")] == ["1700000001.000"]
@pytest.mark.asyncio
async def test_file_id_persistence() -> None:
seed_state(
{"channels": {"C1": {"id": "C1", "name": "general", "context_team_id": "T_MOCK"}}, "messages": {"C1": []}}
)
first = await upload_file("C1", "a.txt", "YQ==")
snapshot = get_state().model_dump(mode="json", exclude_none=True)
state_from_json(snapshot)
second = await upload_file("C1", "b.txt", "Yg==")
assert second["file"]["id"] != first["file"]["id"]
state_from_json(
{
"bot_user_id": "U_MOCK_BOT",
"users": {"U_MOCK_BOT": {"id": "U_MOCK_BOT", "name": "bot"}},
"channels": {"C1": {"id": "C1", "name": "general", "context_team_id": "T_MOCK"}},
"messages": {
"C1": [
{
"ts": "1700000001.000",
"text": "old",
"user": "U_MOCK_BOT",
"type": "message",
"files": [{"id": "F050000", "name": "old.txt", "created": 1, "timestamp": 1}],
}
]
},
}
)
assert get_state().counters.fileId == 50000
next_file = await upload_file("C1", "next.txt", "Yg==")
assert int(next_file["file"]["id"].replace("F", "")) > 50000
@pytest.mark.asyncio
async def test_channel_id_persistence() -> None:
state_from_json(
{
"bot_user_id": "U_MOCK_BOT",
"users": {"U_MOCK_BOT": {"id": "U_MOCK_BOT", "name": "bot"}},
"channels": {"C050000": {"id": "C050000", "name": "general", "context_team_id": "T_MOCK"}},
"messages": {"C050000": []},
}
)
assert get_state().counters.channelId == 50001
next_channel = await create_channel("next")
assert int(next_channel["channel"]["id"].replace("C", "")) > 50000
def test_generated_timestamps_are_monotonic_after_seeded_state(monkeypatch) -> None:
state_from_json(
{
"bot_user_id": "U_MOCK_BOT",
"users": {"U_MOCK_BOT": {"id": "U_MOCK_BOT", "name": "bot"}},
"channels": {"C1": {"id": "C1", "name": "general", "context_team_id": "T_MOCK"}},
"messages": {
"C1": [
{
"ts": "2000000000.999999",
"text": "seeded future message",
"user": "U_MOCK_BOT",
"type": "message",
}
]
},
}
)
monkeypatch.setattr("slack_mock.state.time.time", lambda: 1_700_000_001.123456)
generated = [generate_timestamp() for _ in range(5)]
assert generated == [
"2000000001.000000",
"2000000001.000001",
"2000000001.000002",
"2000000001.000003",
"2000000001.000004",
]
def test_state_import_replaces_timestamp_cursor(monkeypatch) -> None:
state_from_json(
{
"channels": {"C1": {"id": "C1", "name": "general", "context_team_id": "T_MOCK"}},
"messages": {
"C1": [{"ts": "2000000000.000000", "text": "future", "user": "U_MOCK_BOT", "type": "message"}]
},
}
)
state_from_json(
{
"channels": {"C1": {"id": "C1", "name": "general", "context_team_id": "T_MOCK"}},
"messages": {"C1": [{"ts": "1700000001.000000", "text": "older", "user": "U_MOCK_BOT", "type": "message"}]},
}
)
monkeypatch.setattr("slack_mock.state.time.time", lambda: 1_700_000_001.123456)
assert generate_timestamp() == "1700000001.123456"
def test_merge_inputdir_files_combines_workspace_shaped_files(tmp_path: Path) -> None:
first = tmp_path / "a.json"
second = tmp_path / "b.json"
first.write_text(
json.dumps(
{
"workspaces": {
"acme": {
"channels": {"C1": {"id": "C1", "name": "general", "context_team_id": "T_MOCK"}},
"messages": {},
}
}
}
),
encoding="utf-8",
)
second.write_text(
json.dumps(
{
"workspaces": {
"globex": {
"channels": {"C2": {"id": "C2", "name": "ops", "context_team_id": "T_MOCK"}},
"messages": {},
}
}
}
),
encoding="utf-8",
)
merged = merge_inputdir_files([first, second])
assert set(merged["workspaces"]) == {"acme", "globex"}
def seed_viewer_state() -> None:
seed_state(
{
"channels": {
"C001": {
"id": "C001",
"name": "general",
"topic": {"value": "General chat"},
"purpose": {"value": "Team comms"},
"is_private": False,
"is_archived": False,
"num_members": 5,
},
"C002": {
"id": "C002",
"name": "random",
"topic": {"value": ""},
"purpose": {"value": ""},
"is_private": False,
"is_archived": False,
"num_members": 3,
},
},
"messages": {
"C001": [
{
"ts": "1700000001.000",
"text": "Hello world",
"user": "U001",
"type": "message",
"reply_count": 1,
"reactions": [{"name": "wave", "users": ["U002", "U001", "U_MOCK_BOT"], "count": 3}],
},
{
"ts": "1700000002.000",
"text": "Reply in thread",
"user": "U002",
"type": "message",
"thread_ts": "1700000001.000",
},
{
"ts": "1700000003.000",
"text": "Another message",
"user": "U001",
"type": "message",
"files": [{"id": "F100", "name": "report.txt", "mimetype": "text/plain", "size": 11}],
},
],
"C002": [],
},
"users": {
"U001": {
"id": "U001",
"name": "alice",
"real_name": "Alice Smith",
"profile": {
"display_name": "alice",
"title": "Engineer",
"email": "alice@test.com",
"status_text": "Coding",
"status_emoji": ":computer:",
},
"is_bot": False,
"deleted": False,
},
"U002": {
"id": "U002",
"name": "bob",
"real_name": "Bob Jones",
"profile": {
"display_name": "bob",
"title": "Manager",
"email": "bob@test.com",
"status_text": "",
"status_emoji": "",
},
"is_bot": False,
"deleted": False,
},
},
}
)
def test_viewer_api() -> None:
seed_viewer_state()
client = TestClient(create_slack_viewer_app())
channels = client.get("/api/channels")
assert channels.status_code == 200
assert [channel["name"] for channel in channels.json()["channels"]] == ["general", "random"]
general = next(channel for channel in channels.json()["channels"] if channel["id"] == "C001")
assert general["messageCount"] == 3
messages = client.get("/api/channels/C001/messages")
assert messages.status_code == 200
assert messages.json()["channel"]["name"] == "general"
assert len(messages.json()["messages"]) >= 1
first_message = messages.json()["messages"][0]
assert {"text", "time", "user_name", "has_thread"}.issubset(first_message)
assert client.get("/api/channels/NOPE/messages").status_code == 404
assert len(client.get("/api/channels/C001/messages?limit=1").json()["messages"]) <= 1
thread = client.get("/api/threads/C001/1700000001.000")
assert thread.status_code == 200
assert isinstance(thread.json()["messages"], list)
users = client.get("/api/users")
assert users.status_code == 200
assert len(users.json()["users"]) == 3
alice = next(user for user in users.json()["users"] if user["id"] == "U001")
assert alice["display_name"] == "alice"
assert alice["title"] == "Engineer"
def test_viewer_serves_html_index() -> None:
seed_viewer_state()
client = TestClient(create_slack_viewer_app())
response = client.get("/")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
# Body is loaded from the sibling viewer.html asset, not inlined in the module.
assert "<title>Slack</title>" in response.text
def test_viewer_surfaces_message_file_metadata() -> None:
seed_viewer_state()
client = TestClient(create_slack_viewer_app())
messages = client.get("/api/channels/C001/messages").json()["messages"]
with_files = next(message for message in messages if message.get("files"))
file_obj = with_files["files"][0]
assert file_obj["name"] == "report.txt"
assert file_obj["size"] == 11
# Only display metadata is surfaced; stored bytes are never exposed.
assert "content_base64" not in file_obj
home = client.get("/")
assert home.status_code == 200
assert "html" in home.headers["content-type"]
assert "channel-list" in home.text
assert "message-pane" in home.text
def test_viewer_derives_thread_count_when_reply_metadata_missing() -> None:
# Hand-authored seed: parent has replies but omits reply_count (as the
# sample bundle's C004 thread does). The viewer must still surface the
# thread so the reply -- filtered out of the main list -- stays reachable.
seed_state(
{
"channels": {"C1": {"id": "C1", "name": "general", "context_team_id": "T_MOCK"}},
"messages": {
"C1": [
{"ts": "1700000001.000", "text": "parent", "user": "U_MOCK_BOT", "type": "message"},
{
"ts": "1700000002.000",
"text": "reply",
"user": "U_MOCK_BOT",
"type": "message",
"thread_ts": "1700000001.000",
},
]
},
}
)
client = TestClient(create_slack_viewer_app())
messages = client.get("/api/channels/C1/messages").json()["messages"]
# The reply is filtered out; only the parent remains in the main list.
assert [message["ts"] for message in messages] == ["1700000001.000"]
parent = messages[0]
assert parent["has_thread"] is True
assert parent["reply_count"] == 1
# The thread endpoint can still return the parent + reply.
thread = client.get("/api/threads/C1/1700000001.000").json()["messages"]
assert [message["ts"] for message in thread] == ["1700000001.000", "1700000002.000"]