Files
handbook/docker/packages/slack/tests/test_state_snapshot_viewer.py
T
2026-06-24 14:16:58 -07:00

548 lines
20 KiB
Python

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"]