Implement Manticore fully and re-theme

This commit is contained in:
2026-03-11 02:19:08 +00:00
parent da044be68c
commit cbedcd67f6
46 changed files with 3444 additions and 944 deletions

View File

@@ -1,14 +1,11 @@
from __future__ import annotations
from unittest.mock import patch
from django.test import TestCase
from django.urls import reverse
from core.models import (
ContactAvailabilityEvent,
ContactAvailabilitySettings,
Person,
User,
)
from core.models import ContactAvailabilitySettings, ChatSession, ConversationEvent, Person, PersonIdentifier, User
class AvailabilitySettingsPageTests(TestCase):
@@ -17,13 +14,13 @@ class AvailabilitySettingsPageTests(TestCase):
self.client.force_login(self.user)
def test_get_page_renders(self):
response = self.client.get(reverse("availability_settings"))
response = self.client.get(reverse("behavioral_signals_settings"))
self.assertEqual(200, response.status_code)
self.assertContains(response, "Availability Settings")
self.assertContains(response, "Behavioral Signals")
def test_post_updates_settings(self):
response = self.client.post(
reverse("availability_settings"),
reverse("behavioral_signals_settings"),
{
"enabled": "1",
"show_in_chat": "1",
@@ -42,35 +39,70 @@ class AvailabilitySettingsPageTests(TestCase):
self.assertEqual(120, row.retention_days)
self.assertEqual(300, row.fade_threshold_seconds)
def test_contact_event_stats_are_aggregated(self):
@patch("core.views.availability.get_behavioral_availability_stats")
def test_behavioral_manticore_stats_are_in_context(self, mocked_stats):
person = Person.objects.create(user=self.user, name="Alice")
ContactAvailabilityEvent.objects.create(
user=self.user,
person=person,
service="whatsapp",
source_kind="message_in",
availability_state="available",
confidence=0.9,
ts=1000,
payload={},
)
ContactAvailabilityEvent.objects.create(
user=self.user,
person=person,
service="whatsapp",
source_kind="inferred_timeout",
availability_state="fading",
confidence=0.5,
ts=2000,
payload={},
)
response = self.client.get(reverse("availability_settings"))
mocked_stats.return_value = [
{
"person_id": str(person.id),
"transport": "whatsapp",
"total_events": 9,
"presence_events": 2,
"read_events": 3,
"typing_events": 2,
"message_events": 1,
"abandoned_events": 1,
"last_event_ts": 5555,
}
]
response = self.client.get(reverse("behavioral_signals_settings"))
self.assertEqual(200, response.status_code)
stats = list(response.context["contact_stats"])
stats = list(response.context["behavioral_stats"])
self.assertEqual(1, len(stats))
self.assertEqual("Alice", stats[0]["person__name"])
self.assertEqual(2, stats[0]["total_events"])
self.assertEqual(1, stats[0]["available_events"])
self.assertEqual(1, stats[0]["fading_events"])
self.assertEqual(1, stats[0]["message_activity_events"])
self.assertEqual(1, stats[0]["inferred_timeout_events"])
self.assertEqual("Alice", stats[0]["person_name"])
self.assertEqual(1, stats[0]["abandoned_events"])
self.assertEqual("manticore", response.context["behavioral_stats_source"])
self.assertContains(response, "Behavioral Event Statistics")
self.assertNotIn("contact_stats", response.context)
self.assertNotIn("parity_rows", response.context)
@patch("core.views.availability.get_behavioral_availability_stats")
def test_behavioral_stats_fallback_to_conversation_event_shadow(self, mocked_stats):
person = Person.objects.create(user=self.user, name="Alice")
identifier = PersonIdentifier.objects.create(
user=self.user,
person=person,
service="signal",
identifier="+15551230000",
)
session = ChatSession.objects.create(user=self.user, identifier=identifier)
ConversationEvent.objects.create(
user=self.user,
session=session,
ts=1234,
event_type="presence_available",
direction="system",
origin_transport="signal",
)
mocked_stats.return_value = []
response = self.client.get(reverse("behavioral_signals_settings"))
self.assertEqual(200, response.status_code)
stats = list(response.context["behavioral_stats"])
self.assertEqual(1, len(stats))
self.assertEqual("Alice", stats[0]["person_name"])
self.assertEqual(1, int(stats[0]["presence_events"]))
self.assertEqual(
"conversation_event_shadow", response.context["behavioral_stats_source"]
)
def test_legacy_availability_url_redirects(self):
response = self.client.get(reverse("availability_settings"))
self.assertEqual(302, response.status_code)
self.assertIn(
reverse("behavioral_signals_settings"),
str(response.headers.get("Location") or ""),
)

View File

@@ -1,16 +1,11 @@
from __future__ import annotations
from unittest.mock import patch
from django.core.management import call_command
from django.test import TestCase
from core.models import (
ChatSession,
ContactAvailabilityEvent,
Message,
Person,
PersonIdentifier,
User,
)
from core.models import ChatSession, Message, Person, PersonIdentifier, User
from core.presence.inference import now_ms
@@ -30,7 +25,8 @@ class BackfillContactAvailabilityCommandTests(TestCase):
user=self.user, identifier=self.identifier
)
def test_backfill_creates_message_and_read_receipt_availability_events(self):
@patch("core.management.commands.backfill_contact_availability.append_event_sync")
def test_backfill_replays_message_and_read_receipt_events(self, mocked_append):
base_ts = now_ms()
Message.objects.create(
user=self.user,
@@ -61,12 +57,5 @@ class BackfillContactAvailabilityCommandTests(TestCase):
"100",
)
events = list(
ContactAvailabilityEvent.objects.filter(user=self.user).order_by(
"ts", "source_kind"
)
)
self.assertEqual(3, len(events))
self.assertTrue(any(row.source_kind == "message_in" for row in events))
self.assertTrue(any(row.source_kind == "message_out" for row in events))
self.assertTrue(any(row.source_kind == "read_receipt" for row in events))
event_types = [call.kwargs["event_type"] for call in mocked_append.call_args_list]
self.assertEqual(["message_created", "message_created", "read_receipt"], event_types)

View File

@@ -0,0 +1,161 @@
from __future__ import annotations
from io import StringIO
from unittest.mock import Mock, patch
from asgiref.sync import async_to_sync
from django.core.management import call_command
from django.test import TestCase, override_settings
from core.events.ledger import append_event_sync
from core.events.manticore import ManticoreEventLedgerBackend
from core.messaging import history
from core.models import (
ChatSession,
ConversationEvent,
Message,
Person,
PersonIdentifier,
User,
)
@override_settings(
EVENT_LEDGER_DUAL_WRITE=True,
MANTICORE_HTTP_URL="http://manticore.test:9308",
MANTICORE_EVENT_TABLE="gia_events_test",
)
class BehavioralEventPlatformTests(TestCase):
def setUp(self):
ManticoreEventLedgerBackend._table_ready_cache.clear()
self.user = User.objects.create_user(
username="behavior-user",
email="behavior@example.com",
password="x",
)
self.person = Person.objects.create(user=self.user, name="Behavior Person")
self.identifier = PersonIdentifier.objects.create(
user=self.user,
person=self.person,
service="whatsapp",
identifier="15550001234",
)
self.session = ChatSession.objects.create(
user=self.user,
identifier=self.identifier,
)
@patch("core.events.manticore.requests.post")
def test_append_event_dual_writes_to_manticore_and_django(self, mocked_post):
mocked_response = Mock()
mocked_response.json.side_effect = [{}, {}]
mocked_response.raise_for_status.return_value = None
mocked_post.return_value = mocked_response
row = append_event_sync(
user=self.user,
session=self.session,
ts=1700000000000,
event_type="delivery_receipt",
direction="system",
actor_identifier="15550001234",
origin_transport="whatsapp",
origin_message_id="wamid.001",
payload={"message_ts": 1699999999000},
)
self.assertIsNotNone(row)
self.assertEqual(1, ConversationEvent.objects.count())
self.assertEqual(2, mocked_post.call_count)
replace_query = mocked_post.call_args_list[-1].kwargs["data"]["query"]
self.assertIn("REPLACE INTO gia_events_test", replace_query)
self.assertIn("message_delivered", replace_query)
@override_settings(
EVENT_LEDGER_DUAL_WRITE=False,
EVENT_PRIMARY_WRITE_PATH=True,
MANTICORE_HTTP_URL="http://manticore.test:9308",
MANTICORE_EVENT_TABLE="gia_events_primary",
)
@patch("core.events.manticore.requests.post")
def test_primary_write_path_skips_django_rows(self, mocked_post):
mocked_response = Mock()
mocked_response.json.side_effect = [{}, {}]
mocked_response.raise_for_status.return_value = None
mocked_post.return_value = mocked_response
row = append_event_sync(
user=self.user,
session=self.session,
ts=1700000000100,
event_type="message_created",
direction="out",
origin_transport="signal",
origin_message_id="1700000000100",
)
self.assertIsNone(row)
self.assertEqual(0, ConversationEvent.objects.count())
self.assertEqual(2, mocked_post.call_count)
@patch("core.events.manticore.requests.post")
def test_delivery_receipts_write_delivery_event_type(self, mocked_post):
mocked_response = Mock()
mocked_response.json.side_effect = [{}, {}]
mocked_response.raise_for_status.return_value = None
mocked_post.return_value = mocked_response
message = Message.objects.create(
user=self.user,
session=self.session,
ts=1700000000200,
sender_uuid="BOT",
text="hello",
custom_author="BOT",
source_service="whatsapp",
source_message_id="wamid.002",
source_chat_id="15550001234",
)
updated = async_to_sync(history.apply_read_receipts)(
self.user,
self.identifier,
[message.ts],
read_ts=1700000000300,
source_service="whatsapp",
read_by_identifier="15550001234",
payload={"type": "delivered"},
receipt_event_type="delivery_receipt",
)
self.assertEqual(1, updated)
event = ConversationEvent.objects.get()
self.assertEqual("delivery_receipt", event.event_type)
self.assertEqual("delivery_receipt", event.payload.get("receipt_event_type"))
message.refresh_from_db()
self.assertEqual(1700000000300, message.delivered_ts)
self.assertIsNone(message.read_ts)
@patch("core.management.commands.manticore_backfill.upsert_conversation_event")
def test_backfill_command_replays_conversation_events(self, mocked_upsert):
ConversationEvent.objects.create(
user=self.user,
session=self.session,
ts=1700000000400,
event_type="read_receipt",
direction="system",
origin_transport="signal",
origin_message_id="msg-123",
)
out = StringIO()
call_command(
"manticore_backfill",
"--from-conversation-events",
"--user-id",
str(self.user.id),
stdout=out,
)
self.assertEqual(1, mocked_upsert.call_count)
self.assertIn("manticore-backfill scanned=1 indexed=1", out.getvalue())

View File

@@ -1,4 +1,5 @@
from io import StringIO
from unittest.mock import patch
from django.core.management import call_command
from django.core.management.base import CommandError
@@ -89,3 +90,29 @@ class EventLedgerSmokeCommandTests(TestCase):
fail_if_empty=True,
stdout=StringIO(),
)
@patch("core.management.commands.event_ledger_smoke.get_recent_event_rows")
def test_smoke_command_falls_back_to_manticore_rows(self, mocked_rows):
mocked_rows.return_value = [
{
"id": "",
"user_id": int(self.user.id),
"session_id": str(self.session.id),
"ts": 1770000000002,
"event_type": "message_created",
"kind": "message_sent",
"direction": "in",
"origin_transport": "signal",
"trace_id": "",
}
]
out = StringIO()
call_command(
"event_ledger_smoke",
user_id=str(self.user.id),
minutes=999999,
stdout=out,
)
rendered = out.getvalue()
self.assertIn("source=manticore", rendered)
self.assertIn("event_type_counts=", rendered)

View File

@@ -1,5 +1,6 @@
import time
from io import StringIO
from unittest.mock import patch
from django.core.management import call_command
from django.test import TestCase, override_settings
@@ -105,6 +106,38 @@ class EventProjectionShadowTests(TestCase):
1,
)
@patch("core.events.projection.get_session_event_rows")
def test_shadow_compare_can_project_from_manticore_rows(self, mocked_rows):
message = Message.objects.create(
user=self.user,
session=self.session,
ts=1700000000000,
sender_uuid="+15555550333",
text="hello",
delivered_ts=1700000000000,
read_ts=1700000000500,
)
mocked_rows.return_value = [
{
"ts": 1700000000000,
"event_type": "message_created",
"origin_transport": "signal",
"origin_message_id": str(message.id),
"payload": {"message_id": str(message.id), "text": "hello"},
},
{
"ts": 1700000000500,
"event_type": "read_receipt",
"origin_transport": "signal",
"origin_message_id": str(message.id),
"payload": {"message_id": str(message.id), "read_ts": 1700000000500},
},
]
compared = shadow_compare_session(self.session, detail_limit=10)
self.assertEqual(0, compared["counters"]["missing_in_projection"])
def test_management_command_emits_summary(self):
out = StringIO()
call_command(

View File

@@ -0,0 +1,219 @@
from __future__ import annotations
import time
from io import StringIO
from unittest.mock import patch
from django.core.management import call_command
from django.test import SimpleTestCase, TestCase, override_settings
from core.events.behavior import ComposingTracker, summarize_metrics
from core.events.manticore import ManticoreEventLedgerBackend
from core.models import ChatSession, ConversationEvent, Person, PersonIdentifier, User
class ComposingTrackerTests(SimpleTestCase):
def test_tracker_emits_abandoned_after_window(self):
tracker = ComposingTracker(window_ms=300000)
tracker.observe_started("session-1", 1000)
abandoned = tracker.observe_stopped("session-1", 301500)
self.assertIsNotNone(abandoned)
self.assertEqual(300500, abandoned["duration_ms"])
self.assertTrue(abandoned["abandoned"])
class BehavioralMetricSummaryTests(SimpleTestCase):
def test_summarize_metrics_computes_core_intervals(self):
rows = [
{
"session_id": "s1",
"kind": "message_delivered",
"ts": 1000,
"payload": {"message_id": "m1"},
},
{
"session_id": "s1",
"kind": "message_read",
"ts": 1600,
"payload": {"message_id": "m1"},
},
{"session_id": "s1", "kind": "presence_available", "ts": 2000},
{"session_id": "s1", "kind": "composing_started", "ts": 2100},
{"session_id": "s1", "kind": "composing_started", "ts": 2200},
{"session_id": "s1", "kind": "message_sent", "ts": 2600},
{"session_id": "s2", "kind": "composing_started", "ts": 4000},
{"session_id": "s2", "kind": "composing_abandoned", "ts": 710000},
]
metrics = summarize_metrics(rows, rows)
self.assertEqual(600, metrics["delay_b"]["value_ms"])
self.assertEqual(500, metrics["delay_c"]["value_ms"])
self.assertEqual(150, metrics["delay_f"]["value_ms"])
self.assertEqual(2, metrics["revision"]["value_ms"])
self.assertEqual(333, metrics["abandoned_rate"]["value_ms"])
@override_settings(
EVENT_LEDGER_DUAL_WRITE=True,
MANTICORE_HTTP_URL="http://manticore.test:9308",
MANTICORE_EVENT_TABLE="gia_events_test",
MANTICORE_METRIC_TABLE="gia_metrics_test",
)
class GiaAnalysisCommandTests(TestCase):
def setUp(self):
ManticoreEventLedgerBackend._table_ready_cache.clear()
self.user = User.objects.create_user(
username="analysis-user",
email="analysis@example.com",
password="x",
)
self.person = Person.objects.create(user=self.user, name="Analysis Person")
self.identifier = PersonIdentifier.objects.create(
user=self.user,
person=self.person,
service="whatsapp",
identifier="15550009999",
)
self.session = ChatSession.objects.create(
user=self.user,
identifier=self.identifier,
)
@patch("core.events.manticore.requests.post")
def test_metrics_table_upsert_and_analysis_command(self, mocked_post):
now_ms = int(time.time() * 1000)
mocked_response = type(
"Response",
(),
{
"raise_for_status": lambda self: None,
"json": lambda self: {},
},
)
mocked_post.return_value = mocked_response()
ConversationEvent.objects.create(
user=self.user,
session=self.session,
ts=now_ms - 5000,
event_type="presence_available",
direction="system",
origin_transport="whatsapp",
payload={},
)
ConversationEvent.objects.create(
user=self.user,
session=self.session,
ts=now_ms - 4500,
event_type="typing_started",
direction="in",
origin_transport="whatsapp",
payload={},
)
ConversationEvent.objects.create(
user=self.user,
session=self.session,
ts=now_ms - 3900,
event_type="message_created",
direction="in",
origin_transport="whatsapp",
payload={"message_id": "m1"},
)
ConversationEvent.objects.create(
user=self.user,
session=self.session,
ts=now_ms - 3600,
event_type="delivery_receipt",
direction="system",
origin_transport="whatsapp",
payload={"message_id": "m1"},
)
ConversationEvent.objects.create(
user=self.user,
session=self.session,
ts=now_ms - 3000,
event_type="read_receipt",
direction="system",
origin_transport="whatsapp",
payload={"message_id": "m1"},
)
out = StringIO()
with patch(
"core.management.commands.gia_analysis.get_event_ledger_backend"
) as mocked_backend:
backend = mocked_backend.return_value
backend.list_event_targets.return_value = [
{"user_id": self.user.id, "person_id": str(self.person.id)}
]
backend.fetch_events.return_value = [
{
"user_id": self.user.id,
"person_id": str(self.person.id),
"session_id": str(self.session.id),
"kind": "presence_available",
"direction": "system",
"ts": now_ms - 5000,
"payload": {},
},
{
"user_id": self.user.id,
"person_id": str(self.person.id),
"session_id": str(self.session.id),
"kind": "composing_started",
"direction": "in",
"ts": now_ms - 4500,
"payload": {},
},
{
"user_id": self.user.id,
"person_id": str(self.person.id),
"session_id": str(self.session.id),
"kind": "message_sent",
"direction": "in",
"ts": now_ms - 3900,
"payload": {"message_id": "m1"},
},
{
"user_id": self.user.id,
"person_id": str(self.person.id),
"session_id": str(self.session.id),
"kind": "message_delivered",
"direction": "system",
"ts": now_ms - 3600,
"payload": {"message_id": "m1"},
},
{
"user_id": self.user.id,
"person_id": str(self.person.id),
"session_id": str(self.session.id),
"kind": "message_read",
"direction": "system",
"ts": now_ms - 3000,
"payload": {"message_id": "m1"},
},
]
call_command("gia_analysis", "--once", "--user-id", str(self.user.id), stdout=out)
self.assertGreaterEqual(backend.upsert_metric.call_count, 3)
self.assertIn("gia-analysis wrote=", out.getvalue())
@patch("core.events.manticore.requests.post")
def test_list_event_targets_uses_group_by_query(self, mocked_post):
mocked_response = type(
"Response",
(),
{
"raise_for_status": lambda self: None,
"json": lambda self: {"data": []},
},
)
mocked_post.return_value = mocked_response()
backend = ManticoreEventLedgerBackend()
backend.list_event_targets(user_id=1)
query = mocked_post.call_args.kwargs["data"]["query"]
self.assertIn("GROUP BY user_id, person_id", query)

View File

@@ -2,21 +2,13 @@ from __future__ import annotations
from django.test import TestCase
from core.models import (
ContactAvailabilityEvent,
ContactAvailabilitySettings,
ContactAvailabilitySpan,
Person,
PersonIdentifier,
User,
)
from core.models import ContactAvailabilitySettings, Person, PersonIdentifier, User
from core.presence.engine import (
AvailabilitySignal,
ensure_fading_state,
record_inferred_signal,
record_native_signal,
)
from core.presence.inference import now_ms
class PresenceEngineTests(TestCase):
@@ -31,118 +23,45 @@ class PresenceEngineTests(TestCase):
service="signal",
identifier="+15550001111",
)
ContactAvailabilitySettings.objects.update_or_create(
user=self.user,
defaults={
"enabled": True,
"show_in_chat": True,
"show_in_groups": True,
"inference_enabled": True,
"retention_days": 90,
"fade_threshold_seconds": 1,
},
)
def test_read_receipt_signal_creates_available_event(self):
ts = now_ms()
event = record_native_signal(
AvailabilitySignal(
user=self.user,
person=self.person,
person_identifier=self.identifier,
service="signal",
source_kind="read_receipt",
availability_state="available",
confidence=0.95,
ts=ts,
payload={"origin": "test"},
)
)
self.assertIsNotNone(event)
self.assertEqual(
1, ContactAvailabilityEvent.objects.filter(user=self.user).count()
)
self.assertEqual("available", event.availability_state)
def test_inactivity_transitions_to_fading(self):
base_ts = now_ms()
record_inferred_signal(
AvailabilitySignal(
user=self.user,
person=self.person,
person_identifier=self.identifier,
service="signal",
source_kind="read_receipt",
availability_state="available",
confidence=0.95,
ts=base_ts,
)
)
fade_event = ensure_fading_state(
def test_record_native_signal_is_a_compatibility_noop(self):
signal = AvailabilitySignal(
user=self.user,
person=self.person,
person_identifier=self.identifier,
service="signal",
at_ts=base_ts + 10_000,
source_kind="read_receipt",
availability_state="available",
confidence=0.95,
ts=1234,
payload={"origin": "test"},
)
self.assertIsNotNone(fade_event)
self.assertEqual("fading", fade_event.availability_state)
result = record_native_signal(signal)
self.assertIs(result, signal)
def test_explicit_unavailable_blocks_fade_inference(self):
base_ts = now_ms()
record_native_signal(
AvailabilitySignal(
user=self.user,
person=self.person,
person_identifier=self.identifier,
service="xmpp",
source_kind="native_presence",
availability_state="unavailable",
confidence=1.0,
ts=base_ts,
)
def test_record_inferred_signal_respects_settings(self):
ContactAvailabilitySettings.objects.update_or_create(
user=self.user,
defaults={"enabled": True, "inference_enabled": False},
)
fade_event = ensure_fading_state(
signal = AvailabilitySignal(
user=self.user,
person=self.person,
person_identifier=self.identifier,
service="xmpp",
at_ts=base_ts + 60_000,
)
self.assertIsNone(fade_event)
self.assertEqual(
1, ContactAvailabilityEvent.objects.filter(user=self.user).count()
service="signal",
source_kind="typing_stop",
availability_state="fading",
ts=1234,
)
self.assertIsNone(record_inferred_signal(signal))
def test_adjacent_same_state_events_extend_single_span(self):
ts0 = now_ms()
record_native_signal(
AvailabilitySignal(
def test_ensure_fading_state_no_longer_persists_shadow_rows(self):
self.assertIsNone(
ensure_fading_state(
user=self.user,
person=self.person,
person_identifier=self.identifier,
service="signal",
source_kind="typing_start",
availability_state="available",
confidence=0.9,
ts=ts0,
at_ts=1234,
)
)
record_native_signal(
AvailabilitySignal(
user=self.user,
person=self.person,
person_identifier=self.identifier,
service="signal",
source_kind="message_in",
availability_state="available",
confidence=0.8,
ts=ts0 + 5_000,
)
)
spans = list(
ContactAvailabilitySpan.objects.filter(user=self.user).order_by("start_ts")
)
self.assertEqual(1, len(spans))
self.assertEqual(ts0, spans[0].start_ts)
self.assertEqual(ts0 + 5_000, spans[0].end_ts)

View File

@@ -1,14 +1,11 @@
from __future__ import annotations
from django.test import TestCase
from unittest.mock import patch
from core.clients import transport
from core.models import Person, PersonIdentifier, PlatformChatLink, User
from core.presence import (
AvailabilitySignal,
latest_state_for_people,
record_native_signal,
)
from core.models import ChatSession, ConversationEvent, Person, PersonIdentifier, PlatformChatLink, User
from core.presence import latest_state_for_people
from core.presence.inference import now_ms
from core.views.compose import (
_compose_availability_payload,
@@ -28,19 +25,16 @@ class PresenceQueryAndComposeContextTests(TestCase):
identifier="15551234567",
)
def test_latest_state_map_uses_string_person_keys(self):
record_native_signal(
AvailabilitySignal(
user=self.user,
person=self.person,
person_identifier=self.identifier,
service="signal",
source_kind="message_in",
availability_state="available",
confidence=0.8,
ts=now_ms(),
)
)
@patch("core.presence.query.get_behavioral_latest_states")
def test_latest_state_map_uses_string_person_keys(self, mocked_states):
mocked_states.return_value = [
{
"person_id": str(self.person.id),
"transport": "signal",
"kind": "presence_available",
"ts": now_ms(),
}
]
state_map = latest_state_for_people(
user=self.user,
person_ids=[str(self.person.id)],
@@ -48,6 +42,29 @@ class PresenceQueryAndComposeContextTests(TestCase):
)
self.assertIn(str(self.person.id), state_map)
@patch("core.presence.query.get_behavioral_latest_states")
def test_latest_state_map_falls_back_to_conversation_event_shadow(
self, mocked_states
):
session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
ConversationEvent.objects.create(
user=self.user,
session=session,
ts=now_ms(),
event_type="presence_available",
direction="system",
origin_transport="signal",
)
mocked_states.return_value = []
state_map = latest_state_for_people(
user=self.user,
person_ids=[str(self.person.id)],
service="signal",
)
self.assertEqual("available", str(state_map[str(self.person.id)]["state"]))
def test_context_base_matches_signal_identifier_variants(self):
base = _context_base(
user=self.user,
@@ -58,20 +75,29 @@ class PresenceQueryAndComposeContextTests(TestCase):
self.assertIsNotNone(base["person_identifier"])
self.assertEqual(str(self.person.id), str(base["person"].id))
def test_compose_availability_payload_falls_back_to_cross_service(self):
@patch("core.presence.query.get_behavioral_latest_states")
@patch("core.presence.query.get_behavioral_events_for_range")
def test_compose_availability_payload_falls_back_to_cross_service(
self, mocked_events, mocked_states
):
ts_value = now_ms()
record_native_signal(
AvailabilitySignal(
user=self.user,
person=self.person,
person_identifier=self.identifier,
service="whatsapp",
source_kind="message_in",
availability_state="available",
confidence=0.9,
ts=ts_value,
)
)
mocked_events.return_value = [
{
"person_id": str(self.person.id),
"transport": "whatsapp",
"kind": "presence_available",
"ts": ts_value,
"payload": {},
}
]
mocked_states.return_value = [
{
"person_id": str(self.person.id),
"transport": "whatsapp",
"kind": "presence_available",
"ts": ts_value,
}
]
enabled, slices, summary = _compose_availability_payload(
user=self.user,
person=self.person,
@@ -85,6 +111,132 @@ class PresenceQueryAndComposeContextTests(TestCase):
self.assertEqual("available", str(summary.get("state")))
self.assertTrue(bool(summary.get("is_cross_service")))
@patch("core.presence.query.get_behavioral_latest_states")
@patch("core.presence.query.get_behavioral_events_for_range")
def test_compose_availability_payload_can_fallback_to_manticore_behavioral(
self,
mocked_events,
mocked_states,
):
ts_value = now_ms()
mocked_events.return_value = [
{
"person_id": str(self.person.id),
"transport": "signal",
"kind": "presence_available",
"ts": ts_value - 30000,
"payload": {},
},
{
"person_id": str(self.person.id),
"transport": "signal",
"kind": "composing_abandoned",
"ts": ts_value - 5000,
"payload": {},
},
]
mocked_states.return_value = [
{
"person_id": str(self.person.id),
"transport": "signal",
"kind": "composing_abandoned",
"ts": ts_value - 5000,
}
]
enabled, slices, summary = _compose_availability_payload(
user=self.user,
person=self.person,
service="signal",
range_start=ts_value - 60000,
range_end=ts_value + 60000,
)
self.assertTrue(enabled)
self.assertGreaterEqual(len(slices), 1)
self.assertEqual("signal", str(slices[0].get("service")))
self.assertEqual("fading", str(summary.get("state")))
self.assertEqual(
"behavioral:composing_abandoned",
str(summary.get("source_kind")),
)
@patch("core.presence.query.get_behavioral_latest_states")
@patch("core.presence.query.get_behavioral_events_for_range")
def test_compose_availability_payload_falls_back_to_conversation_event_shadow(
self,
mocked_events,
mocked_states,
):
ts_value = now_ms()
session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
ConversationEvent.objects.create(
user=self.user,
session=session,
ts=ts_value - 10000,
event_type="presence_available",
direction="system",
origin_transport="signal",
)
mocked_events.return_value = []
mocked_states.return_value = []
enabled, slices, summary = _compose_availability_payload(
user=self.user,
person=self.person,
service="signal",
range_start=ts_value - 60000,
range_end=ts_value + 60000,
)
self.assertTrue(enabled)
self.assertGreaterEqual(len(slices), 1)
self.assertEqual("signal", str(slices[0].get("service")))
self.assertEqual("available", str(summary.get("state")))
@patch("core.presence.query.get_behavioral_latest_states")
@patch("core.presence.query.get_behavioral_events_for_range")
def test_compose_availability_payload_prefers_manticore_over_django(
self,
mocked_events,
mocked_states,
):
ts_value = now_ms()
mocked_events.return_value = [
{
"person_id": str(self.person.id),
"transport": "signal",
"kind": "composing_abandoned",
"ts": ts_value - 5000,
"payload": {},
}
]
mocked_states.return_value = [
{
"person_id": str(self.person.id),
"transport": "signal",
"kind": "composing_abandoned",
"ts": ts_value - 5000,
}
]
enabled, slices, summary = _compose_availability_payload(
user=self.user,
person=self.person,
service="signal",
range_start=ts_value - 60000,
range_end=ts_value + 60000,
)
self.assertTrue(enabled)
self.assertGreaterEqual(len(slices), 1)
self.assertEqual("fading", str(slices[0].get("state")))
self.assertEqual("fading", str(summary.get("state")))
self.assertEqual(
"behavioral:composing_abandoned",
str(summary.get("source_kind")),
)
def test_context_base_preserves_native_signal_group_identifier(self):
PlatformChatLink.objects.create(
user=self.user,
@@ -104,6 +256,33 @@ class PresenceQueryAndComposeContextTests(TestCase):
self.assertTrue(bool(base["is_group"]))
self.assertEqual("signal-group-123", str(base["identifier"]))
def test_context_base_prefers_explicit_signal_group_over_xmpp_identifier_match(self):
PlatformChatLink.objects.create(
user=self.user,
service="signal",
chat_identifier="signal-group-123",
chat_name="Signal Group",
is_group=True,
)
xmpp_person = Person.objects.create(user=self.user, name="Bridge Alias")
PersonIdentifier.objects.create(
user=self.user,
person=xmpp_person,
service="xmpp",
identifier="signal-group-123",
)
base = _context_base(
user=self.user,
service="signal",
identifier="signal-group-123",
person=None,
)
self.assertEqual("signal", str(base["service"]))
self.assertTrue(bool(base["is_group"]))
self.assertIsNone(base["person_identifier"])
def test_manual_contact_rows_include_signal_groups(self):
PlatformChatLink.objects.create(
user=self.user,

View File

@@ -0,0 +1,80 @@
from __future__ import annotations
from io import StringIO
from django.core.management import call_command
from django.test import TestCase, override_settings
from core.models import ChatSession, ConversationEvent, Person, PersonIdentifier, User
@override_settings(CONVERSATION_EVENT_RETENTION_DAYS=90)
class PruneBehavioralOrmDataCommandTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username="prune-user",
email="prune@example.com",
password="x",
)
self.person = Person.objects.create(user=self.user, name="Prune Person")
self.identifier = PersonIdentifier.objects.create(
user=self.user,
person=self.person,
service="signal",
identifier="+15555550111",
)
self.session = ChatSession.objects.create(
user=self.user,
identifier=self.identifier,
)
def test_prune_command_deletes_old_shadow_rows(self):
ConversationEvent.objects.create(
user=self.user,
session=self.session,
ts=1000,
event_type="message_created",
direction="in",
origin_transport="signal",
)
out = StringIO()
call_command("prune_behavioral_orm_data", stdout=out)
self.assertEqual(0, ConversationEvent.objects.count())
self.assertIn("prune-behavioral-orm-data", out.getvalue())
def test_prune_command_supports_dry_run(self):
ConversationEvent.objects.create(
user=self.user,
session=self.session,
ts=1000,
event_type="message_created",
direction="in",
origin_transport="signal",
)
out = StringIO()
call_command("prune_behavioral_orm_data", "--dry-run", stdout=out)
self.assertEqual(1, ConversationEvent.objects.count())
self.assertIn("dry_run=True", out.getvalue())
def test_prune_command_can_limit_tables(self):
ConversationEvent.objects.create(
user=self.user,
session=self.session,
ts=1000,
event_type="message_created",
direction="in",
origin_transport="signal",
)
call_command(
"prune_behavioral_orm_data",
"--tables",
"conversation_events",
stdout=StringIO(),
)
self.assertEqual(0, ConversationEvent.objects.count())

View File

@@ -47,6 +47,15 @@ class SettingsIntegrityTests(TestCase):
self.assertIsNotNone(settings_nav)
self.assertEqual("Modules", settings_nav["title"])
def test_behavioral_settings_receives_modules_settings_nav(self):
response = self.client.get(reverse("behavioral_signals_settings"))
self.assertEqual(200, response.status_code)
settings_nav = response.context.get("settings_nav")
self.assertIsNotNone(settings_nav)
self.assertEqual("Modules", settings_nav["title"])
labels = [str(item["label"]) for item in settings_nav["tabs"]]
self.assertIn("Behavioral Signals", labels)
def test_tasks_settings_cross_links_commands_and_permissions(self):
TaskProject.objects.create(user=self.user, name="Integrity Project")
response = self.client.get(reverse("tasks_settings"))

View File

@@ -1,5 +1,6 @@
from django.test import TestCase
from django.urls import reverse
from unittest.mock import patch
from core.models import (
AIRequest,
@@ -63,6 +64,31 @@ class SystemDiagnosticsAPITests(TestCase):
self.assertIn("missing_required_types", payload)
self.assertIn("reaction_added", payload.get("missing_required_types") or [])
@patch("core.views.system.get_recent_event_rows")
def test_event_ledger_smoke_api_can_use_manticore_source(self, mocked_rows):
mocked_rows.return_value = [
{
"id": "",
"user_id": int(self.user.id),
"session_id": str(self.session.id),
"ts": 1700000000000,
"event_type": "message_created",
"kind": "message_sent",
"direction": "in",
"origin_transport": "signal",
"trace_id": "",
}
]
response = self.client.get(
reverse("system_event_ledger_smoke"),
{"minutes": "999999", "service": "signal"},
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertTrue(payload.get("ok"))
self.assertEqual("manticore", payload.get("data_source"))
self.assertEqual(1, int(payload.get("count") or 0))
def test_trace_diagnostics_includes_projection_shadow_links(self):
trace_id = "trace-system-diag-1"
event = ConversationEvent.objects.create(
@@ -95,6 +121,44 @@ class SystemDiagnosticsAPITests(TestCase):
str(events[0].get("projection_shadow_url") or ""),
)
@patch("core.views.system.get_trace_event_rows")
def test_trace_diagnostics_can_use_manticore_source(self, mocked_rows):
mocked_rows.return_value = [
{
"id": "",
"ts": 1700000001000,
"event_type": "message_created",
"direction": "in",
"session_id": str(self.session.id),
"origin_transport": "signal",
"origin_message_id": "m2",
"payload": {"trace_id": "trace-system-diag-m"},
}
]
response = self.client.get(
reverse("system_trace_diagnostics"),
{"trace_id": "trace-system-diag-m"},
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertTrue(payload.get("ok"))
self.assertEqual("manticore", payload.get("data_source"))
self.assertEqual(1, int(payload.get("count") or 0))
self.assertIn(str(self.session.id), payload.get("related_session_ids") or [])
@patch("core.views.system.get_trace_ids")
@patch("core.views.system.count_behavioral_events")
def test_system_settings_page_includes_manticore_trace_ids(
self, mocked_behavioral_count, mocked_trace_ids
):
mocked_behavioral_count.return_value = 7
mocked_trace_ids.return_value = ["trace-from-manticore"]
response = self.client.get(reverse("system_settings"))
self.assertEqual(200, response.status_code)
content = response.content.decode("utf-8")
self.assertIn("trace-from-manticore", content)
self.assertIn("Behavioral Events: 7", content)
def test_memory_search_status_and_query_api(self):
request = AIRequest.objects.create(
user=self.user,
@@ -127,7 +191,18 @@ class SystemDiagnosticsAPITests(TestCase):
first_hit = (query_payload.get("hits") or [{}])[0]
self.assertEqual(str(memory.id), str(first_hit.get("memory_id") or ""))
def test_system_settings_page_renders_searchable_datalists(self):
@patch("core.views.system.get_recent_event_rows")
@patch("core.views.system.count_behavioral_events")
def test_system_settings_page_renders_searchable_datalists(
self, mocked_behavioral_count, mocked_recent_rows
):
mocked_behavioral_count.return_value = 3
mocked_recent_rows.return_value = [
{
"event_type": "presence_available",
"origin_transport": "whatsapp",
}
]
ConversationEvent.objects.create(
user=self.user,
session=self.session,
@@ -148,3 +223,5 @@ class SystemDiagnosticsAPITests(TestCase):
self.assertIn('datalist id="diagnostics-event-type-options"', content)
self.assertIn(str(self.session.id), content)
self.assertIn("trace-system-diag-2", content)
self.assertIn("whatsapp", content)
self.assertIn("presence_available", content)

View File

@@ -3,6 +3,8 @@ from __future__ import annotations
import asyncio
from asgiref.sync import async_to_sync
from unittest.mock import patch
from django.core.management import call_command
from django.test import TestCase
@@ -10,8 +12,6 @@ from core.clients.whatsapp import WhatsAppClient
from core.messaging import history, media_bridge
from core.models import (
ChatSession,
ContactAvailabilityEvent,
ContactAvailabilitySpan,
Message,
Person,
PersonIdentifier,
@@ -175,6 +175,36 @@ class WhatsAppReactionHandlingTests(TestCase):
)
self.assertEqual("caption from wrapper", text)
def test_message_identifier_candidates_use_chat_for_direct_outbound(self):
values = self.client._message_identifier_candidates(
sender="441234567890@s.whatsapp.net",
chat="447356114729@s.whatsapp.net",
is_from_me=True,
)
self.assertIn("447356114729@s.whatsapp.net", values)
self.assertIn("447356114729", values)
self.assertNotIn("441234567890@s.whatsapp.net", values)
def test_message_identifier_candidates_use_sender_for_direct_inbound(self):
values = self.client._message_identifier_candidates(
sender="447356114729@s.whatsapp.net",
chat="441234567890@s.whatsapp.net",
is_from_me=False,
)
self.assertIn("447356114729@s.whatsapp.net", values)
self.assertIn("447356114729", values)
self.assertNotIn("441234567890@s.whatsapp.net", values)
def test_message_identifier_candidates_use_group_chat_for_group_events(self):
values = self.client._message_identifier_candidates(
sender="447356114729@s.whatsapp.net",
chat="120363402761690215@g.us",
is_from_me=True,
)
self.assertIn("120363402761690215@g.us", values)
self.assertIn("120363402761690215", values)
self.assertNotIn("447356114729@s.whatsapp.net", values)
class RecalculateContactAvailabilityTests(TestCase):
def setUp(self):
@@ -215,42 +245,21 @@ class RecalculateContactAvailabilityTests(TestCase):
},
)
def _projection(self):
events = list(
ContactAvailabilityEvent.objects.filter(user=self.user)
.order_by("ts", "source_kind", "id")
.values_list("service", "source_kind", "availability_state", "ts")
)
spans = list(
ContactAvailabilitySpan.objects.filter(user=self.user)
.order_by("start_ts", "end_ts", "id")
.values_list("service", "state", "start_ts", "end_ts")
)
return events, spans
def test_recalculate_is_deterministic_and_no_skew_on_rerun(self):
@patch("core.management.commands.recalculate_contact_availability.append_event_sync")
def test_recalculate_replays_message_read_and_reaction_events(self, mocked_append):
call_command(
"recalculate_contact_availability", "--days", "36500", "--limit", "500"
)
first_events, first_spans = self._projection()
self.assertTrue(first_events)
self.assertTrue(first_spans)
call_command(
"recalculate_contact_availability", "--days", "36500", "--limit", "500"
event_types = [call.kwargs["event_type"] for call in mocked_append.call_args_list]
self.assertEqual(
["message_created", "read_receipt", "presence_available"],
event_types,
)
second_events, second_spans = self._projection()
self.assertEqual(first_events, second_events)
self.assertEqual(first_spans, second_spans)
def test_recalculate_no_reset_does_not_duplicate(self):
call_command(
"recalculate_contact_availability", "--days", "36500", "--limit", "500"
)
events_before = ContactAvailabilityEvent.objects.filter(user=self.user).count()
spans_before = ContactAvailabilitySpan.objects.filter(user=self.user).count()
@patch("core.management.commands.recalculate_contact_availability.append_event_sync")
def test_recalculate_no_reset_remains_idempotent_at_command_interface(
self, mocked_append
):
call_command(
"recalculate_contact_availability",
"--days",
@@ -259,7 +268,4 @@ class RecalculateContactAvailabilityTests(TestCase):
"500",
"--no-reset",
)
events_after = ContactAvailabilityEvent.objects.filter(user=self.user).count()
spans_after = ContactAvailabilitySpan.objects.filter(user=self.user).count()
self.assertEqual(events_before, events_after)
self.assertEqual(spans_before, spans_after)
self.assertEqual(3, mocked_append.call_count)