Implement executing tasks

This commit is contained in:
2026-03-03 16:41:28 +00:00
parent d6bd56dace
commit 9c14e51b43
42 changed files with 3410 additions and 121 deletions

View 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)

View 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))

View 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)

View 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)

View 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))

View 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, ""))

View 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)