from io import StringIO import time 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.projection import shadow_compare_session from core.models import ChatSession, Message, Person, PersonIdentifier, User @override_settings(EVENT_LEDGER_DUAL_WRITE=True) class EventProjectionShadowTests(TestCase): def setUp(self): self.user = User.objects.create_user( username="projection-user", email="projection@example.com", password="x", ) self.person = Person.objects.create(user=self.user, name="Projection Person") self.identifier = PersonIdentifier.objects.create( user=self.user, person=self.person, service="signal", identifier="+15555550333", ) self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier) def test_shadow_compare_has_zero_mismatch_when_projection_matches(self): message = Message.objects.create( user=self.user, session=self.session, ts=1700000000000, sender_uuid="+15555550333", text="hello", delivered_ts=1700000000000, read_ts=1700000000500, receipt_payload={ "reactions": [ { "source_service": "signal", "actor": "user:1:signal", "emoji": "👍", "removed": False, } ] }, ) append_event_sync( user=self.user, session=self.session, ts=1700000000000, event_type="message_created", direction="in", origin_transport="signal", origin_message_id=str(message.id), payload={"message_id": str(message.id), "text": "hello"}, ) append_event_sync( user=self.user, session=self.session, ts=1700000000500, event_type="read_receipt", direction="system", origin_transport="signal", origin_message_id=str(message.id), payload={"message_id": str(message.id), "read_ts": 1700000000500}, ) append_event_sync( user=self.user, session=self.session, ts=1700000000600, event_type="reaction_added", direction="system", actor_identifier="user:1:signal", origin_transport="signal", origin_message_id=str(message.id), payload={ "message_id": str(message.id), "emoji": "👍", "source_service": "signal", "actor": "user:1:signal", }, ) compared = shadow_compare_session(self.session, detail_limit=10) self.assertEqual(0, compared["mismatch_total"]) def test_shadow_compare_detects_missing_projection_row(self): Message.objects.create( user=self.user, session=self.session, ts=1700000000000, sender_uuid="+15555550333", text="no-event", ) compared = shadow_compare_session(self.session, detail_limit=10) self.assertGreater(compared["counters"]["missing_in_projection"], 0) def test_management_command_emits_summary(self): out = StringIO() call_command( "event_projection_shadow", user_id=str(self.user.id), limit_sessions=5, stdout=out, ) rendered = out.getvalue() self.assertIn("shadow compare:", rendered) self.assertIn("cause_counts=", rendered) def test_management_command_supports_service_and_recent_filters(self): Message.objects.create( user=self.user, session=self.session, ts=int(time.time() * 1000), sender_uuid="+15550000000", text="recent", source_service="signal", source_message_id="recent-1", ) out = StringIO() call_command( "event_projection_shadow", user_id=str(self.user.id), service="signal", recent_minutes=120, limit_sessions=5, stdout=out, ) rendered = out.getvalue() self.assertIn("shadow compare:", rendered)