Implement Manticore fully and re-theme
This commit is contained in:
@@ -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 ""),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
161
core/tests/test_behavioral_event_platform.py
Normal file
161
core/tests/test_behavioral_event_platform.py
Normal 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())
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
219
core/tests/test_gia_analysis.py
Normal file
219
core/tests/test_gia_analysis.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
80
core/tests/test_prune_behavioral_orm_data.py
Normal file
80
core/tests/test_prune_behavioral_orm_data.py
Normal 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())
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user