fix: persist Chatwoot outbound message timeline
This commit is contained in:
200
tests/test_chatwoot_outbound_factual_timeline.py
Normal file
200
tests/test_chatwoot_outbound_factual_timeline.py
Normal file
@@ -0,0 +1,200 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import app.webhooks_chatwoot as webhook
|
||||
import app.persistence as persistence
|
||||
from app.persistence import _message_created_at
|
||||
|
||||
|
||||
def _payload(*, private=False, message_id=712, content="Resposta pública"):
|
||||
return {
|
||||
"event": "message_created",
|
||||
"id": message_id,
|
||||
"message_type": "outgoing",
|
||||
"private": private,
|
||||
"content": content,
|
||||
"created_at": 1_700_000_000,
|
||||
"conversation": {"id": 2450},
|
||||
"sender": {"id": 9, "name": "Operador", "type": "user"},
|
||||
}
|
||||
|
||||
|
||||
def test_chatwoot_timestamp_preserves_epoch_and_iso():
|
||||
assert _message_created_at(1_700_000_000) == datetime.fromtimestamp(1_700_000_000, tz=timezone.utc)
|
||||
assert _message_created_at("2026-08-15T10:20:30Z") == datetime(2026, 8, 15, 10, 20, 30, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_outgoing_public_persists_fact_before_existing_outgoing_processing(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(webhook, "save_factual_chatwoot_message", lambda **kw: (calls.append(("message", kw)) or ("msg-1", True)))
|
||||
monkeypatch.setattr(webhook, "auto_complete_task_from_outgoing_message", lambda **kw: (calls.append(("outgoing", kw)) or {"status": "no_matching_pending_task"}))
|
||||
monkeypatch.setattr(webhook, "mark_raw_event_processed", lambda **kw: calls.append(("processed", kw)))
|
||||
|
||||
result = asyncio.run(webhook.process_saved_chatwoot_raw_event("00000000-0000-0000-0000-000000000001", _payload()))
|
||||
|
||||
assert [call[0] for call in calls] == ["message", "outgoing", "processed"]
|
||||
assert calls[0][1]["direction"] == "outbound"
|
||||
assert calls[0][1]["source_event_id"] == "712"
|
||||
assert calls[0][1]["contact_id"] is None
|
||||
assert calls[0][1]["source_created_at"] == 1_700_000_000
|
||||
assert result["message_id"] == "msg-1"
|
||||
|
||||
|
||||
def test_outgoing_public_does_not_enter_inbound_analysis(monkeypatch):
|
||||
monkeypatch.setattr(webhook, "save_factual_chatwoot_message", lambda **kw: ("msg-1", True))
|
||||
monkeypatch.setattr(webhook, "auto_complete_task_from_outgoing_message", lambda **kw: {"status": "no_matching_pending_task"})
|
||||
monkeypatch.setattr(webhook, "mark_raw_event_processed", lambda **kw: None)
|
||||
|
||||
async def forbidden(*args, **kwargs):
|
||||
raise AssertionError("outgoing factual messages must not enter classification")
|
||||
|
||||
monkeypatch.setattr(webhook, "analyze", forbidden)
|
||||
asyncio.run(webhook.process_saved_chatwoot_raw_event("00000000-0000-0000-0000-000000000001", _payload()))
|
||||
|
||||
|
||||
def test_outgoing_private_note_is_not_persisted_as_public_message(monkeypatch):
|
||||
monkeypatch.setattr(webhook, "save_factual_chatwoot_message", lambda **kw: (_ for _ in ()).throw(AssertionError("private note persisted")))
|
||||
monkeypatch.setattr(webhook, "auto_complete_task_from_outgoing_message", lambda **kw: {"status": "ignored_private_note"})
|
||||
monkeypatch.setattr(webhook, "mark_raw_event_processed", lambda **kw: None)
|
||||
|
||||
result = asyncio.run(webhook.process_saved_chatwoot_raw_event("00000000-0000-0000-0000-000000000001", _payload(private=True)))
|
||||
assert result["message_id"] is None
|
||||
|
||||
|
||||
def test_string_false_private_value_is_public():
|
||||
assert webhook.extract_chatwoot_event(_payload(private="false"))["is_private"] is False
|
||||
|
||||
|
||||
def test_non_message_created_outgoing_event_is_not_persisted(monkeypatch):
|
||||
payload = _payload()
|
||||
payload["event"] = "conversation_updated"
|
||||
monkeypatch.setattr(webhook, "save_factual_chatwoot_message", lambda **kw: (_ for _ in ()).throw(AssertionError("wrong event persisted")))
|
||||
monkeypatch.setattr(webhook, "auto_complete_task_from_outgoing_message", lambda **kw: {"status": "no_matching_pending_task"})
|
||||
monkeypatch.setattr(webhook, "mark_raw_event_processed", lambda **kw: None)
|
||||
asyncio.run(webhook.process_saved_chatwoot_raw_event("00000000-0000-0000-0000-000000000001", payload))
|
||||
|
||||
|
||||
class _Rows:
|
||||
def __init__(self, row=None):
|
||||
self.row = row
|
||||
|
||||
def first(self):
|
||||
return self.row
|
||||
|
||||
|
||||
class _PersistenceConnection:
|
||||
def __init__(self):
|
||||
self.message_id = None
|
||||
self.inserts = 0
|
||||
|
||||
def execute(self, statement, params=None):
|
||||
sql = str(statement)
|
||||
if "pg_advisory_xact_lock" in sql:
|
||||
return _Rows()
|
||||
if "SELECT id::text FROM messages" in sql:
|
||||
return _Rows((self.message_id,)) if self.message_id else _Rows()
|
||||
if "INSERT INTO messages" in sql:
|
||||
self.inserts += 1
|
||||
self.message_id = "00000000-0000-0000-0000-000000000099"
|
||||
return _Rows((self.message_id,))
|
||||
if "UPDATE raw_events SET message_id" in sql:
|
||||
return _Rows()
|
||||
raise AssertionError(sql)
|
||||
|
||||
|
||||
class _Begin:
|
||||
def __init__(self, connection):
|
||||
self.connection = connection
|
||||
|
||||
def __enter__(self):
|
||||
return self.connection
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
|
||||
class _PersistenceEngine:
|
||||
def __init__(self):
|
||||
self.connection = _PersistenceConnection()
|
||||
|
||||
def begin(self):
|
||||
return _Begin(self.connection)
|
||||
|
||||
|
||||
def test_factual_persistence_is_idempotent_by_source_identity(monkeypatch):
|
||||
fake = _PersistenceEngine()
|
||||
monkeypatch.setattr(persistence, "engine", fake)
|
||||
monkeypatch.setattr(persistence.settings, "clientflow_persist", True)
|
||||
kwargs = {
|
||||
"raw_event_id": "00000000-0000-0000-0000-000000000001",
|
||||
"source_event_id": "712",
|
||||
"conversation_id": "2450",
|
||||
"contact_id": None,
|
||||
"direction": "outbound",
|
||||
"raw_body": "Resposta",
|
||||
"source_created_at": 1_700_000_000,
|
||||
}
|
||||
first = persistence.save_factual_chatwoot_message(**kwargs)
|
||||
second = persistence.save_factual_chatwoot_message(**kwargs)
|
||||
assert first[1] is True
|
||||
assert second == (first[0], False)
|
||||
assert fake.connection.inserts == 1
|
||||
|
||||
|
||||
class _BackfillPresence:
|
||||
def __init__(self, present=False):
|
||||
self.present = present
|
||||
|
||||
def execute(self, statement, params=None):
|
||||
return _Rows((1,)) if self.present else _Rows()
|
||||
|
||||
def rollback(self):
|
||||
return None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
|
||||
class _BackfillEngine:
|
||||
def __init__(self, presence):
|
||||
self.presence = presence
|
||||
|
||||
def connect(self):
|
||||
return self.presence
|
||||
|
||||
|
||||
def test_outbound_backfill_dry_run_apply_and_repeat(monkeypatch):
|
||||
import scripts.backfill_chatwoot_outbound_messages as backfill
|
||||
|
||||
row = {
|
||||
"id": "00000000-0000-0000-0000-000000000001",
|
||||
"source_event_id": "712",
|
||||
"conversation_id": "2450",
|
||||
"contact_id": None,
|
||||
"payload": _payload(),
|
||||
"created_at": datetime(2026, 8, 15, tzinfo=timezone.utc),
|
||||
}
|
||||
presence = _BackfillPresence(False)
|
||||
monkeypatch.setattr(backfill, "_candidate_rows", lambda: [row])
|
||||
monkeypatch.setattr(backfill, "engine", _BackfillEngine(presence))
|
||||
writes = []
|
||||
|
||||
def save(**kwargs):
|
||||
writes.append(kwargs)
|
||||
presence.present = True
|
||||
return "msg-1", True
|
||||
|
||||
monkeypatch.setattr(backfill, "save_factual_chatwoot_message", save)
|
||||
|
||||
assert backfill.backfill(apply=False) == {
|
||||
"candidates": 1, "inserted": 0, "already_present": 0, "skipped": 0, "errors": 0,
|
||||
}
|
||||
assert writes == []
|
||||
assert backfill.backfill(apply=True)["inserted"] == 1
|
||||
repeated = backfill.backfill(apply=True)
|
||||
assert repeated["inserted"] == 0
|
||||
assert repeated["already_present"] == 1
|
||||
assert len(writes) == 1
|
||||
Reference in New Issue
Block a user