Implement executing tasks
This commit is contained in:
38
core/tests/test_availability_settings_page.py
Normal file
38
core/tests/test_availability_settings_page.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from core.models import ContactAvailabilitySettings, User
|
||||
|
||||
|
||||
class AvailabilitySettingsPageTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user("avail-user", "avail@example.com", "x")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_get_page_renders(self):
|
||||
response = self.client.get(reverse("availability_settings"))
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertContains(response, "Availability Settings")
|
||||
|
||||
def test_post_updates_settings(self):
|
||||
response = self.client.post(
|
||||
reverse("availability_settings"),
|
||||
{
|
||||
"enabled": "1",
|
||||
"show_in_chat": "1",
|
||||
"show_in_groups": "0",
|
||||
"inference_enabled": "1",
|
||||
"retention_days": "120",
|
||||
"fade_threshold_seconds": "300",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code)
|
||||
row = ContactAvailabilitySettings.objects.get(user=self.user)
|
||||
self.assertTrue(row.enabled)
|
||||
self.assertTrue(row.show_in_chat)
|
||||
self.assertFalse(row.show_in_groups)
|
||||
self.assertTrue(row.inference_enabled)
|
||||
self.assertEqual(120, row.retention_days)
|
||||
self.assertEqual(300, row.fade_threshold_seconds)
|
||||
66
core/tests/test_backfill_contact_availability.py
Normal file
66
core/tests/test_backfill_contact_availability.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
|
||||
from core.models import (
|
||||
ChatSession,
|
||||
ContactAvailabilityEvent,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
Message,
|
||||
User,
|
||||
)
|
||||
from core.presence.inference import now_ms
|
||||
|
||||
|
||||
class BackfillContactAvailabilityCommandTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user("backfill-user", "backfill@example.com", "x")
|
||||
self.person = Person.objects.create(user=self.user, name="Backfill Person")
|
||||
self.identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
service="signal",
|
||||
identifier="+15551234567",
|
||||
)
|
||||
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
|
||||
|
||||
def test_backfill_creates_message_and_read_receipt_availability_events(self):
|
||||
base_ts = now_ms()
|
||||
Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
ts=base_ts,
|
||||
text="hello",
|
||||
source_service="signal",
|
||||
source_chat_id="+15551234567",
|
||||
custom_author="OTHER",
|
||||
)
|
||||
Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
ts=base_ts + 1000,
|
||||
text="hey",
|
||||
source_service="signal",
|
||||
source_chat_id="+15551234567",
|
||||
custom_author="USER",
|
||||
read_ts=base_ts + 2000,
|
||||
read_by_identifier="+15551234567",
|
||||
)
|
||||
|
||||
call_command(
|
||||
"backfill_contact_availability",
|
||||
"--days",
|
||||
"36500",
|
||||
"--limit",
|
||||
"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))
|
||||
60
core/tests/test_codex_cli_provider.py
Normal file
60
core/tests/test_codex_cli_provider.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from subprocess import CompletedProcess, TimeoutExpired
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from core.tasks.providers.codex_cli import CodexCLITaskProvider
|
||||
|
||||
|
||||
class CodexCLITaskProviderTests(SimpleTestCase):
|
||||
def setUp(self):
|
||||
self.provider = CodexCLITaskProvider()
|
||||
|
||||
@patch("core.tasks.providers.codex_cli.subprocess.run")
|
||||
def test_healthcheck_success(self, run_mock):
|
||||
run_mock.return_value = CompletedProcess(
|
||||
args=["codex", "--version"],
|
||||
returncode=0,
|
||||
stdout="codex 1.2.3\n",
|
||||
stderr="",
|
||||
)
|
||||
result = self.provider.healthcheck({"command": "codex", "timeout_seconds": 5})
|
||||
self.assertTrue(result.ok)
|
||||
self.assertIn("codex", str(result.payload.get("stdout") or ""))
|
||||
|
||||
@patch("core.tasks.providers.codex_cli.subprocess.run")
|
||||
def test_create_task_builds_task_sync_command(self, run_mock):
|
||||
run_mock.return_value = CompletedProcess(
|
||||
args=[],
|
||||
returncode=0,
|
||||
stdout='{"external_key":"cx-123"}',
|
||||
stderr="",
|
||||
)
|
||||
result = self.provider.create_task(
|
||||
{
|
||||
"command": "codex",
|
||||
"workspace_root": "/tmp/work",
|
||||
"default_profile": "default",
|
||||
"timeout_seconds": 30,
|
||||
},
|
||||
{
|
||||
"task_id": "t1",
|
||||
"title": "hello",
|
||||
"reference_code": "42",
|
||||
},
|
||||
)
|
||||
self.assertTrue(result.ok)
|
||||
self.assertEqual("cx-123", result.external_key)
|
||||
args = run_mock.call_args.args[0]
|
||||
self.assertEqual(["codex", "task-sync", "--op", "create"], args[:4])
|
||||
self.assertIn("--workspace", args)
|
||||
self.assertIn("--payload-json", args)
|
||||
|
||||
@patch("core.tasks.providers.codex_cli.subprocess.run")
|
||||
def test_timeout_maps_to_failed_result(self, run_mock):
|
||||
run_mock.side_effect = TimeoutExpired(cmd=["codex"], timeout=10)
|
||||
result = self.provider.append_update({"command": "codex", "timeout_seconds": 10}, {"task_id": "t1"})
|
||||
self.assertFalse(result.ok)
|
||||
self.assertIn("timeout", result.error)
|
||||
140
core/tests/test_presence_engine.py
Normal file
140
core/tests/test_presence_engine.py
Normal file
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from core.models import (
|
||||
ContactAvailabilityEvent,
|
||||
ContactAvailabilitySettings,
|
||||
ContactAvailabilitySpan,
|
||||
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):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user("presence-user", "presence@example.com", "x")
|
||||
self.person = Person.objects.create(user=self.user, name="Presence Person")
|
||||
self.identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
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(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
person_identifier=self.identifier,
|
||||
service="signal",
|
||||
at_ts=base_ts + 10_000,
|
||||
)
|
||||
self.assertIsNotNone(fade_event)
|
||||
self.assertEqual("fading", fade_event.availability_state)
|
||||
|
||||
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,
|
||||
)
|
||||
)
|
||||
fade_event = ensure_fading_state(
|
||||
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())
|
||||
|
||||
def test_adjacent_same_state_events_extend_single_span(self):
|
||||
ts0 = now_ms()
|
||||
record_native_signal(
|
||||
AvailabilitySignal(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
person_identifier=self.identifier,
|
||||
service="signal",
|
||||
source_kind="typing_start",
|
||||
availability_state="available",
|
||||
confidence=0.9,
|
||||
ts=ts0,
|
||||
)
|
||||
)
|
||||
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)
|
||||
50
core/tests/test_presence_query_and_compose_context.py
Normal file
50
core/tests/test_presence_query_and_compose_context.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from core.models import Person, PersonIdentifier, User
|
||||
from core.presence import AvailabilitySignal, latest_state_for_people, record_native_signal
|
||||
from core.presence.inference import now_ms
|
||||
from core.views.compose import _context_base
|
||||
|
||||
|
||||
class PresenceQueryAndComposeContextTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user("ctx-user", "ctx@example.com", "x")
|
||||
self.person = Person.objects.create(user=self.user, name="Ctx Person")
|
||||
self.identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
service="signal",
|
||||
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(),
|
||||
)
|
||||
)
|
||||
state_map = latest_state_for_people(
|
||||
user=self.user,
|
||||
person_ids=[str(self.person.id)],
|
||||
service="signal",
|
||||
)
|
||||
self.assertIn(str(self.person.id), state_map)
|
||||
|
||||
def test_context_base_matches_signal_identifier_variants(self):
|
||||
base = _context_base(
|
||||
user=self.user,
|
||||
service="signal",
|
||||
identifier="+1 (555) 123-4567",
|
||||
person=self.person,
|
||||
)
|
||||
self.assertIsNotNone(base["person_identifier"])
|
||||
self.assertEqual(str(self.person.id), str(base["person"].id))
|
||||
31
core/tests/test_signal_text_extraction.py
Normal file
31
core/tests/test_signal_text_extraction.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from core.clients.signal import _extract_signal_text
|
||||
|
||||
|
||||
class SignalTextExtractionTests(SimpleTestCase):
|
||||
def test_extracts_emoji_only_data_message_text(self):
|
||||
payload = {
|
||||
"envelope": {
|
||||
"dataMessage": {
|
||||
"message": "🙂",
|
||||
}
|
||||
}
|
||||
}
|
||||
self.assertEqual("🙂", _extract_signal_text(payload, ""))
|
||||
|
||||
def test_extracts_sync_sent_message_fallback(self):
|
||||
payload = {
|
||||
"envelope": {
|
||||
"syncMessage": {
|
||||
"sentMessage": {
|
||||
"message": {
|
||||
"message": "ok 👍",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.assertEqual("ok 👍", _extract_signal_text(payload, ""))
|
||||
198
core/tests/test_whatsapp_reaction_and_recalc.py
Normal file
198
core/tests/test_whatsapp_reaction_and_recalc.py
Normal file
@@ -0,0 +1,198 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
|
||||
from core.clients.whatsapp import WhatsAppClient
|
||||
from core.messaging import history
|
||||
from core.models import (
|
||||
ChatSession,
|
||||
ContactAvailabilityEvent,
|
||||
ContactAvailabilitySpan,
|
||||
Message,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
User,
|
||||
)
|
||||
from core.presence.inference import now_ms
|
||||
|
||||
|
||||
class _DummyXMPPClient:
|
||||
async def apply_external_reaction(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
class _DummyUR:
|
||||
def __init__(self, loop):
|
||||
self.loop = loop
|
||||
self.xmpp = type("X", (), {"client": _DummyXMPPClient()})()
|
||||
self.presence_calls = []
|
||||
self.stopped_typing_calls = []
|
||||
|
||||
async def presence_changed(self, protocol, *args, **kwargs):
|
||||
self.presence_calls.append((protocol, kwargs))
|
||||
|
||||
async def stopped_typing(self, protocol, *args, **kwargs):
|
||||
self.stopped_typing_calls.append((protocol, kwargs))
|
||||
|
||||
|
||||
class WhatsAppReactionHandlingTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user("wa-rx-user", "wa-rx@example.com", "x")
|
||||
self.person = Person.objects.create(user=self.user, name="WA Person")
|
||||
self.identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
service="whatsapp",
|
||||
identifier="15551234567@s.whatsapp.net",
|
||||
)
|
||||
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
|
||||
self.base_ts = now_ms()
|
||||
self.target = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
ts=self.base_ts,
|
||||
text="hello",
|
||||
source_service="whatsapp",
|
||||
source_message_id="wa-target-1",
|
||||
source_chat_id="120363402761690215@g.us",
|
||||
sender_uuid="15551234567@s.whatsapp.net",
|
||||
)
|
||||
self.loop = asyncio.new_event_loop()
|
||||
self.ur = _DummyUR(self.loop)
|
||||
self.client = WhatsAppClient(self.ur, self.loop, "whatsapp")
|
||||
|
||||
def tearDown(self):
|
||||
try:
|
||||
self.loop.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def test_reaction_event_extract_and_apply_by_source_message_id(self):
|
||||
message_obj = {
|
||||
"reactionMessage": {
|
||||
"text": "👍",
|
||||
"targetMessageKey": {
|
||||
"id": "wa-target-1",
|
||||
"messageTimestamp": int(self.base_ts / 1000),
|
||||
},
|
||||
}
|
||||
}
|
||||
parsed = self.client._extract_reaction_event(message_obj)
|
||||
self.assertIsNotNone(parsed)
|
||||
self.assertEqual("wa-target-1", str(parsed.get("target_message_id") or ""))
|
||||
before_count = Message.objects.filter(user=self.user, session=self.session).count()
|
||||
async_to_sync(history.apply_reaction)(
|
||||
self.user,
|
||||
self.identifier,
|
||||
target_message_id="wa-target-1",
|
||||
target_ts=int(parsed.get("target_ts") or 0),
|
||||
emoji="👍",
|
||||
source_service="whatsapp",
|
||||
actor="15551234567@s.whatsapp.net",
|
||||
remove=False,
|
||||
payload={"event": "reaction"},
|
||||
)
|
||||
after_count = Message.objects.filter(user=self.user, session=self.session).count()
|
||||
self.assertEqual(before_count, after_count)
|
||||
|
||||
self.target.refresh_from_db()
|
||||
reactions = list((self.target.receipt_payload or {}).get("reactions") or [])
|
||||
self.assertEqual(1, len(reactions))
|
||||
self.assertEqual("👍", str(reactions[0].get("emoji") or ""))
|
||||
|
||||
def test_presence_event_emits_native_presence_with_last_seen(self):
|
||||
event = {
|
||||
"From": {"User": "15551234567@s.whatsapp.net"},
|
||||
"Unavailable": True,
|
||||
"LastSeen": int(self.base_ts / 1000),
|
||||
}
|
||||
async_to_sync(self.client._handle_presence_event)(event)
|
||||
self.assertTrue(self.ur.presence_calls)
|
||||
payload = self.ur.presence_calls[-1][1].get("payload") or {}
|
||||
self.assertEqual("offline", payload.get("presence"))
|
||||
self.assertTrue(int(payload.get("last_seen_ts") or 0) > 0)
|
||||
|
||||
|
||||
class RecalculateContactAvailabilityTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user("recalc-user", "recalc@example.com", "x")
|
||||
self.person = Person.objects.create(user=self.user, name="Recalc Person")
|
||||
self.identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
service="whatsapp",
|
||||
identifier="15557654321@s.whatsapp.net",
|
||||
)
|
||||
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
|
||||
self.base_ts = now_ms()
|
||||
|
||||
Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
ts=self.base_ts,
|
||||
text="task",
|
||||
source_service="whatsapp",
|
||||
source_message_id="m-1",
|
||||
source_chat_id="120363402761690215@g.us",
|
||||
sender_uuid="15557654321@s.whatsapp.net",
|
||||
custom_author="OTHER",
|
||||
read_ts=self.base_ts + 20_000,
|
||||
receipt_payload={
|
||||
"reactions": [
|
||||
{
|
||||
"emoji": "🔥",
|
||||
"actor": "15557654321@s.whatsapp.net",
|
||||
"source_service": "whatsapp",
|
||||
"removed": False,
|
||||
"updated_at": self.base_ts + 10_000,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
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):
|
||||
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")
|
||||
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()
|
||||
|
||||
call_command(
|
||||
"recalculate_contact_availability",
|
||||
"--days",
|
||||
"36500",
|
||||
"--limit",
|
||||
"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)
|
||||
Reference in New Issue
Block a user