Implement business plans
This commit is contained in:
138
core/tests/test_bp_fallback.py
Normal file
138
core/tests/test_bp_fallback.py
Normal file
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from django.test import TransactionTestCase
|
||||
|
||||
from core.commands.base import CommandContext
|
||||
from core.commands.handlers.bp import BPCommandHandler
|
||||
from core.models import (
|
||||
AI,
|
||||
BusinessPlanDocument,
|
||||
ChatSession,
|
||||
CommandAction,
|
||||
CommandChannelBinding,
|
||||
CommandProfile,
|
||||
CommandRun,
|
||||
Message,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
class BPFallbackTests(TransactionTestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="bp-fallback-user",
|
||||
email="bp-fallback@example.com",
|
||||
password="x",
|
||||
)
|
||||
self.person = Person.objects.create(user=self.user, name="Fallback Person")
|
||||
self.identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
service="whatsapp",
|
||||
identifier="120363402761690215",
|
||||
)
|
||||
self.session = ChatSession.objects.create(
|
||||
user=self.user,
|
||||
identifier=self.identifier,
|
||||
)
|
||||
self.profile = CommandProfile.objects.create(
|
||||
user=self.user,
|
||||
slug="bp",
|
||||
name="Business Plan",
|
||||
enabled=True,
|
||||
trigger_token="#bp#",
|
||||
reply_required=True,
|
||||
exact_match_only=True,
|
||||
)
|
||||
CommandChannelBinding.objects.create(
|
||||
profile=self.profile,
|
||||
direction="ingress",
|
||||
service="whatsapp",
|
||||
channel_identifier="120363402761690215",
|
||||
enabled=True,
|
||||
)
|
||||
CommandChannelBinding.objects.create(
|
||||
profile=self.profile,
|
||||
direction="egress",
|
||||
service="web",
|
||||
channel_identifier="120363402761690215",
|
||||
enabled=True,
|
||||
)
|
||||
for action_type, position in (
|
||||
("extract_bp", 0),
|
||||
("save_document", 1),
|
||||
("post_result", 2),
|
||||
):
|
||||
CommandAction.objects.create(
|
||||
profile=self.profile,
|
||||
action_type=action_type,
|
||||
enabled=True,
|
||||
position=position,
|
||||
)
|
||||
AI.objects.create(
|
||||
user=self.user,
|
||||
base_url="https://example.invalid",
|
||||
api_key="test-key",
|
||||
model="gpt-4o-mini",
|
||||
)
|
||||
|
||||
def test_bp_falls_back_to_draft_when_ai_fails(self):
|
||||
anchor = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="peer",
|
||||
text="Anchor content",
|
||||
ts=1000,
|
||||
source_service="whatsapp",
|
||||
source_message_id="wa-anchor-1",
|
||||
)
|
||||
trigger = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="peer",
|
||||
text="#bp#",
|
||||
ts=2000,
|
||||
source_service="whatsapp",
|
||||
source_message_id="wa-trigger-1",
|
||||
reply_to=anchor,
|
||||
reply_source_service="whatsapp",
|
||||
reply_source_message_id="wa-anchor-1",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"core.commands.handlers.bp.ai_runner.run_prompt",
|
||||
new=AsyncMock(side_effect=RuntimeError("quota")),
|
||||
):
|
||||
result = async_to_sync(BPCommandHandler().execute)(
|
||||
CommandContext(
|
||||
service="whatsapp",
|
||||
channel_identifier="120363402761690215",
|
||||
message_id=str(trigger.id),
|
||||
user_id=self.user.id,
|
||||
message_text="#bp#",
|
||||
payload={},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(result.ok)
|
||||
run = CommandRun.objects.get(trigger_message=trigger, profile=self.profile)
|
||||
self.assertEqual("ok", run.status)
|
||||
self.assertIn("bp_ai_failed", str(run.error))
|
||||
self.assertTrue(BusinessPlanDocument.objects.filter(trigger_message=trigger).exists())
|
||||
|
||||
def test_bp_uses_same_ai_selection_order_as_compose(self):
|
||||
AI.objects.create(
|
||||
user=self.user,
|
||||
base_url="https://example.invalid",
|
||||
api_key="another-key",
|
||||
model="gpt-4o",
|
||||
)
|
||||
selected = AI.objects.filter(user=self.user).first()
|
||||
# Compose uses QuerySet.first() without explicit ordering; BP should match.
|
||||
self.assertIsNotNone(selected)
|
||||
self.assertEqual(self.profile.user_id, selected.user_id)
|
||||
241
core/tests/test_phase1_command_reply.py
Normal file
241
core/tests/test_phase1_command_reply.py
Normal file
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from django.test import TestCase
|
||||
|
||||
from core.commands.base import CommandContext
|
||||
from core.commands.engine import _matches_trigger, process_inbound_message
|
||||
from core.messaging.reply_sync import extract_reply_ref, resolve_reply_target
|
||||
from core.models import (
|
||||
ChatSession,
|
||||
CommandChannelBinding,
|
||||
CommandProfile,
|
||||
Message,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
class Phase1ReplyResolutionTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="phase1-reply-user",
|
||||
email="phase1-reply@example.com",
|
||||
password="x",
|
||||
)
|
||||
self.person = Person.objects.create(user=self.user, name="Reply Person")
|
||||
self.identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
service="signal",
|
||||
identifier="+15550000001",
|
||||
)
|
||||
self.session = ChatSession.objects.create(
|
||||
user=self.user,
|
||||
identifier=self.identifier,
|
||||
)
|
||||
|
||||
def test_resolve_reply_target_by_source_message_id(self):
|
||||
anchor = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="+15550000001",
|
||||
text="anchor",
|
||||
ts=1000,
|
||||
source_service="signal",
|
||||
source_message_id="signal-msg-1",
|
||||
)
|
||||
resolved = async_to_sync(resolve_reply_target)(
|
||||
self.user,
|
||||
self.session,
|
||||
{
|
||||
"reply_source_service": "signal",
|
||||
"reply_source_message_id": "signal-msg-1",
|
||||
},
|
||||
)
|
||||
self.assertEqual(anchor.id, resolved.id if resolved else None)
|
||||
|
||||
def test_resolve_reply_target_with_bridge_ref_fallback(self):
|
||||
anchor = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="+15550000001",
|
||||
text="anchor",
|
||||
ts=2000,
|
||||
receipt_payload={
|
||||
"bridge_refs": {
|
||||
"signal": [
|
||||
{
|
||||
"xmpp_message_id": "xmpp-bridge-1",
|
||||
"upstream_message_id": "signal-upstream-1",
|
||||
"upstream_author": "+15550000001",
|
||||
"upstream_ts": 2000,
|
||||
"updated_at": 2000,
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
resolved = async_to_sync(resolve_reply_target)(
|
||||
self.user,
|
||||
self.session,
|
||||
{
|
||||
"reply_source_service": "signal",
|
||||
"reply_source_message_id": "signal-upstream-1",
|
||||
},
|
||||
)
|
||||
self.assertEqual(anchor.id, resolved.id if resolved else None)
|
||||
|
||||
def test_resolve_reply_target_miss(self):
|
||||
resolved = async_to_sync(resolve_reply_target)(
|
||||
self.user,
|
||||
self.session,
|
||||
{
|
||||
"reply_source_service": "signal",
|
||||
"reply_source_message_id": "does-not-exist",
|
||||
},
|
||||
)
|
||||
self.assertIsNone(resolved)
|
||||
|
||||
def test_extract_reply_ref_xmpp(self):
|
||||
result = extract_reply_ref(
|
||||
"xmpp",
|
||||
{
|
||||
"reply_source_message_id": "xmpp-msg-1",
|
||||
"reply_source_chat_id": "alice@example.test",
|
||||
},
|
||||
)
|
||||
self.assertEqual("xmpp-msg-1", result.get("reply_source_message_id"))
|
||||
self.assertEqual("xmpp", result.get("reply_source_service"))
|
||||
|
||||
def test_extract_reply_ref_signal(self):
|
||||
result = extract_reply_ref(
|
||||
"signal",
|
||||
{
|
||||
"envelope": {
|
||||
"dataMessage": {
|
||||
"quote": {"id": "signal-msg-quoted"},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
self.assertEqual("signal-msg-quoted", result.get("reply_source_message_id"))
|
||||
self.assertEqual("signal", result.get("reply_source_service"))
|
||||
|
||||
def test_extract_reply_ref_whatsapp(self):
|
||||
result = extract_reply_ref(
|
||||
"whatsapp",
|
||||
{
|
||||
"extendedTextMessage": {
|
||||
"contextInfo": {
|
||||
"stanzaId": "wa-msg-quoted",
|
||||
"participant": "12345@s.whatsapp.net",
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
self.assertEqual("wa-msg-quoted", result.get("reply_source_message_id"))
|
||||
self.assertEqual("whatsapp", result.get("reply_source_service"))
|
||||
|
||||
def test_extract_reply_ref_whatsapp_stanza_id_variant(self):
|
||||
result = extract_reply_ref(
|
||||
"whatsapp",
|
||||
{
|
||||
"extendedTextMessage": {
|
||||
"contextInfo": {
|
||||
"stanzaID": "wa-msg-quoted-2",
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
self.assertEqual("wa-msg-quoted-2", result.get("reply_source_message_id"))
|
||||
self.assertEqual("whatsapp", result.get("reply_source_service"))
|
||||
|
||||
|
||||
class Phase1CommandEngineTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="phase1-command-user",
|
||||
email="phase1-command@example.com",
|
||||
password="x",
|
||||
)
|
||||
self.person = Person.objects.create(user=self.user, name="Command Person")
|
||||
self.identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
service="signal",
|
||||
identifier="+15550000002",
|
||||
)
|
||||
self.session = ChatSession.objects.create(
|
||||
user=self.user,
|
||||
identifier=self.identifier,
|
||||
)
|
||||
self.profile = CommandProfile.objects.create(
|
||||
user=self.user,
|
||||
slug="bp",
|
||||
name="Business Plan",
|
||||
enabled=True,
|
||||
trigger_token="#bp#",
|
||||
reply_required=True,
|
||||
exact_match_only=True,
|
||||
)
|
||||
CommandChannelBinding.objects.create(
|
||||
profile=self.profile,
|
||||
direction="ingress",
|
||||
service="web",
|
||||
channel_identifier="web-chan-1",
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
def test_matches_trigger_exact_only(self):
|
||||
self.assertTrue(_matches_trigger(self.profile, "#bp#"))
|
||||
self.assertFalse(_matches_trigger(self.profile, " #bp# extra "))
|
||||
|
||||
def test_process_inbound_message_requires_reply(self):
|
||||
msg = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="",
|
||||
text="#bp#",
|
||||
ts=3000,
|
||||
source_service="web",
|
||||
source_chat_id="web-chan-1",
|
||||
message_meta={},
|
||||
)
|
||||
results = async_to_sync(process_inbound_message)(
|
||||
CommandContext(
|
||||
service="web",
|
||||
channel_identifier="web-chan-1",
|
||||
message_id=str(msg.id),
|
||||
user_id=self.user.id,
|
||||
message_text="#bp#",
|
||||
payload={},
|
||||
)
|
||||
)
|
||||
self.assertEqual(1, len(results))
|
||||
self.assertEqual("skipped", results[0].status)
|
||||
self.assertEqual("reply_required", results[0].error)
|
||||
|
||||
def test_process_inbound_message_skips_mirrored_origin(self):
|
||||
msg = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="",
|
||||
text="#bp#",
|
||||
ts=4000,
|
||||
source_service="web",
|
||||
source_chat_id="web-chan-1",
|
||||
message_meta={"origin_tag": "translation:test"},
|
||||
)
|
||||
results = async_to_sync(process_inbound_message)(
|
||||
CommandContext(
|
||||
service="web",
|
||||
channel_identifier="web-chan-1",
|
||||
message_id=str(msg.id),
|
||||
user_id=self.user.id,
|
||||
message_text="#bp#",
|
||||
payload={},
|
||||
)
|
||||
)
|
||||
self.assertEqual([], results)
|
||||
38
core/tests/test_whatsapp_send_routing.py
Normal file
38
core/tests/test_whatsapp_send_routing.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from core.clients import transport
|
||||
from core.clients.whatsapp import WhatsAppClient
|
||||
|
||||
|
||||
class WhatsAppSendRoutingTests(TestCase):
|
||||
def setUp(self):
|
||||
self.loop = asyncio.new_event_loop()
|
||||
self.client = WhatsAppClient(ur=None, loop=self.loop)
|
||||
|
||||
def tearDown(self):
|
||||
try:
|
||||
self.loop.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def test_to_jid_prefers_known_group_mapping(self):
|
||||
transport.update_runtime_state(
|
||||
"whatsapp",
|
||||
groups=[
|
||||
{
|
||||
"identifier": "120363402761690215",
|
||||
"jid": "120363402761690215@g.us",
|
||||
}
|
||||
],
|
||||
)
|
||||
jid = self.client._to_jid("120363402761690215")
|
||||
self.assertEqual("120363402761690215@g.us", jid)
|
||||
|
||||
def test_to_jid_keeps_phone_number_for_direct_chat(self):
|
||||
transport.update_runtime_state("whatsapp", groups=[])
|
||||
jid = self.client._to_jid("+14155551212")
|
||||
self.assertEqual("14155551212@s.whatsapp.net", jid)
|
||||
Reference in New Issue
Block a user