Files
konstruct/tests/unit/test_normalize.py
Adolfo Delorenzo 47e78627fd feat(01-foundation-01): Alembic migrations with RLS and tenant isolation tests
- alembic.ini + migrations/env.py: async SQLAlchemy migration setup using asyncpg
- migrations/versions/001_initial_schema.py: creates tenants, agents, channel_connections, portal_users
  - ENABLE + FORCE ROW LEVEL SECURITY on agents and channel_connections
  - RLS policy: tenant_id = current_setting('app.current_tenant', TRUE)::uuid
  - konstruct_app role created with SELECT/INSERT/UPDATE/DELETE on all tables
- packages/shared/shared/rls.py: idempotent configure_rls_hook, UUID-sanitized SET LOCAL
- tests/conftest.py: test_db_name (session-scoped), db_engine + db_session as konstruct_app
- tests/unit/test_normalize.py: 11 tests for KonstructMessage Slack normalization (CHAN-01)
- tests/unit/test_tenant_resolution.py: 7 tests for workspace_id → tenant resolution (TNNT-02)
- tests/unit/test_redis_namespacing.py: 15 tests for Redis key namespace isolation (TNNT-03)
- tests/integration/test_tenant_isolation.py: 7 tests proving RLS tenant isolation (TNNT-01)
  - tenant_b cannot see tenant_a's agents or channel_connections
  - FORCE ROW LEVEL SECURITY verified via pg_class.relforcerowsecurity
2026-03-23 09:57:29 -06:00

144 lines
5.4 KiB
Python

"""
Unit tests for KonstructMessage normalization from Slack event payloads.
Tests CHAN-01: Channel Gateway normalizes messages from all channels into
unified KonstructMessage format.
These tests exercise normalization logic without requiring a live database.
"""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from shared.models.message import ChannelType, KonstructMessage, MessageContent, SenderInfo
def make_slack_event() -> dict:
"""Minimal valid Slack message event payload."""
return {
"type": "message",
"channel": "C12345ABC",
"user": "U98765XYZ",
"text": "Hey @bot can you help me?",
"ts": "1711234567.123456",
"thread_ts": "1711234567.123456",
"team": "T11223344",
"blocks": [],
"event_ts": "1711234567.123456",
}
def normalize_slack_event(payload: dict) -> KonstructMessage:
"""
Minimal normalizer for test purposes.
The real normalizer lives in packages/gateway/channels/slack.py.
This function mirrors the expected output for unit testing the model.
"""
ts = float(payload["ts"])
timestamp = datetime.fromtimestamp(ts, tz=timezone.utc)
return KonstructMessage(
channel=ChannelType.SLACK,
channel_metadata={
"workspace_id": payload["team"],
"channel_id": payload["channel"],
"event_ts": payload["event_ts"],
},
sender=SenderInfo(
user_id=payload["user"],
display_name=payload["user"], # Display name resolved later by Slack API
),
content=MessageContent(
text=payload["text"],
mentions=[u for u in payload["text"].split() if u.startswith("@")],
),
timestamp=timestamp,
thread_id=payload.get("thread_ts"),
reply_to=None if payload.get("thread_ts") == payload.get("ts") else payload.get("thread_ts"),
)
class TestKonstructMessageNormalization:
"""Tests for Slack event normalization to KonstructMessage."""
def test_channel_type_is_slack(self) -> None:
"""ChannelType must be set to 'slack' for Slack events."""
msg = normalize_slack_event(make_slack_event())
assert msg.channel == ChannelType.SLACK
assert msg.channel == "slack"
def test_sender_info_extracted(self) -> None:
"""SenderInfo user_id must match Slack user field."""
payload = make_slack_event()
msg = normalize_slack_event(payload)
assert msg.sender.user_id == "U98765XYZ"
assert msg.sender.is_bot is False
def test_content_text_preserved(self) -> None:
"""MessageContent.text must contain original Slack message text."""
payload = make_slack_event()
msg = normalize_slack_event(payload)
assert msg.content.text == "Hey @bot can you help me?"
def test_thread_id_from_thread_ts(self) -> None:
"""thread_id must be populated from Slack's thread_ts field."""
payload = make_slack_event()
payload["thread_ts"] = "1711234500.000001" # Different from ts — it's a reply
msg = normalize_slack_event(payload)
assert msg.thread_id == "1711234500.000001"
def test_thread_id_none_when_no_thread(self) -> None:
"""thread_id must be None when the message is not in a thread."""
payload = make_slack_event()
del payload["thread_ts"]
msg = normalize_slack_event(payload)
assert msg.thread_id is None
def test_channel_metadata_contains_workspace_id(self) -> None:
"""channel_metadata must contain workspace_id (Slack team ID)."""
payload = make_slack_event()
msg = normalize_slack_event(payload)
assert "workspace_id" in msg.channel_metadata
assert msg.channel_metadata["workspace_id"] == "T11223344"
def test_channel_metadata_contains_channel_id(self) -> None:
"""channel_metadata must contain the Slack channel ID."""
payload = make_slack_event()
msg = normalize_slack_event(payload)
assert msg.channel_metadata["channel_id"] == "C12345ABC"
def test_tenant_id_is_none_before_resolution(self) -> None:
"""tenant_id must be None immediately after normalization (Router populates it)."""
msg = normalize_slack_event(make_slack_event())
assert msg.tenant_id is None
def test_message_has_uuid_id(self) -> None:
"""KonstructMessage must have a UUID id assigned at construction."""
import uuid
msg = normalize_slack_event(make_slack_event())
# Should not raise
parsed = uuid.UUID(msg.id)
assert str(parsed) == msg.id
def test_timestamp_is_utc_datetime(self) -> None:
"""timestamp must be a timezone-aware datetime in UTC."""
msg = normalize_slack_event(make_slack_event())
assert msg.timestamp.tzinfo is not None
assert msg.timestamp.tzinfo == timezone.utc
def test_pydantic_validation_rejects_invalid_channel(self) -> None:
"""KonstructMessage must reject unknown ChannelType values."""
with pytest.raises(Exception): # pydantic.ValidationError
KonstructMessage(
channel="fax_machine", # type: ignore[arg-type]
channel_metadata={},
sender=SenderInfo(user_id="u1", display_name="User"),
content=MessageContent(text="hello"),
timestamp=datetime.now(timezone.utc),
)