Increase platform abstraction cohesion

This commit is contained in:
2026-03-06 17:47:58 +00:00
parent 438e561da0
commit 8c091b1e6d
55 changed files with 6555 additions and 440 deletions

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
import json
from unittest.mock import AsyncMock, patch
from django.test import TestCase
from django.urls import reverse
from core.models import User
class ComposeSendCapabilityTests(TestCase):
def setUp(self):
self.user = User.objects.create_user("compose-send", "send@example.com", "pw")
self.client.force_login(self.user)
@patch("core.views.compose.transport.enqueue_runtime_command")
@patch("core.views.compose.transport.send_message_raw", new_callable=AsyncMock)
def test_unsupported_send_fails_fast_without_dispatch(
self,
mocked_send_message_raw,
mocked_enqueue_runtime_command,
):
response = self.client.post(
reverse("compose_send"),
{
"service": "xmpp",
"identifier": "person@example.com",
"text": "hello",
"failsafe_arm": "1",
"failsafe_confirm": "1",
},
)
self.assertEqual(200, response.status_code)
payload = json.loads(response.headers["HX-Trigger"])["composeSendResult"]
self.assertFalse(payload["ok"])
self.assertEqual("warning", payload["level"])
self.assertIn("Send not supported:", payload["message"])
mocked_send_message_raw.assert_not_awaited()
mocked_enqueue_runtime_command.assert_not_called()
def test_compose_page_displays_send_disabled_reason_for_unsupported_service(self):
response = self.client.get(
reverse("compose_page"),
{
"service": "xmpp",
"identifier": "person@example.com",
},
)
self.assertEqual(200, response.status_code)
content = response.content.decode("utf-8")
self.assertIn("Send disabled:", content)
self.assertIn("compose-send-btn", content)
def test_compose_page_uses_humanized_browser_title(self):
response = self.client.get(
reverse("compose_page"),
{
"service": "signal",
"identifier": "+15551230000",
},
)
self.assertEqual(200, response.status_code)
self.assertContains(response, "<title>Compose Page · GIA</title>", html=False)

View File

@@ -0,0 +1,239 @@
from __future__ import annotations
from pathlib import Path
from django.test import TestCase, override_settings
from core.mcp.tools import execute_tool, tool_specs
from core.models import (
AIRequest,
MCPToolAuditLog,
MemoryItem,
TaskProject,
User,
WorkspaceConversation,
DerivedTask,
DerivedTaskEvent,
)
@override_settings(MEMORY_SEARCH_BACKEND="django")
class MCPToolTests(TestCase):
def setUp(self):
self.user = User.objects.create_superuser(
username="mcp-tools-user",
email="mcp-tools@example.com",
password="pw",
)
self.conversation = WorkspaceConversation.objects.create(
user=self.user,
platform_type="signal",
title="MCP Memory Scope",
platform_thread_id="mcp-thread-1",
)
request = AIRequest.objects.create(
user=self.user,
conversation=self.conversation,
window_spec={},
operation="memory_propose",
)
self.memory = MemoryItem.objects.create(
user=self.user,
conversation=self.conversation,
memory_kind="fact",
status="active",
content={"text": "Prefers concise implementation notes."},
source_request=request,
confidence_score=0.8,
)
self.project = TaskProject.objects.create(user=self.user, name="MCP Project")
self.task = DerivedTask.objects.create(
user=self.user,
project=self.project,
title="Wire MCP server",
source_service="signal",
source_channel="team-chat",
status_snapshot="open",
immutable_payload={"scope": "memory"},
)
DerivedTaskEvent.objects.create(
task=self.task,
event_type="created",
actor_identifier="agent",
payload={"note": "task created"},
)
def test_tool_specs_include_memory_task_wiki_tools(self):
names = {item["name"] for item in tool_specs()}
self.assertIn("manticore.status", names)
self.assertIn("memory.propose", names)
self.assertIn("tasks.link_artifact", names)
self.assertIn("wiki.create_article", names)
self.assertIn("project.get_runbook", names)
def test_manticore_query_and_tasks_tools(self):
memory_payload = execute_tool(
"manticore.query",
{"user_id": self.user.id, "query": "concise implementation"},
)
self.assertGreaterEqual(int(memory_payload.get("count") or 0), 1)
first_hit = (memory_payload.get("hits") or [{}])[0]
self.assertEqual(str(self.memory.id), str(first_hit.get("memory_id")))
list_payload = execute_tool("tasks.list", {"user_id": self.user.id, "limit": 10})
self.assertEqual(1, int(list_payload.get("count") or 0))
self.assertEqual(str(self.task.id), str((list_payload.get("items") or [{}])[0].get("id")))
search_payload = execute_tool(
"tasks.search",
{"user_id": self.user.id, "query": "wire"},
)
self.assertEqual(1, int(search_payload.get("count") or 0))
events_payload = execute_tool("tasks.events", {"task_id": str(self.task.id), "limit": 5})
self.assertEqual(1, int(events_payload.get("count") or 0))
self.assertEqual("created", str((events_payload.get("items") or [{}])[0].get("event_type")))
def test_memory_proposal_review_flow(self):
propose_payload = execute_tool(
"memory.propose",
{
"user_id": self.user.id,
"conversation_id": str(self.conversation.id),
"memory_kind": "fact",
"content": {"field": "likes", "text": "short status bullets"},
"reason": "Operator memory capture",
"requested_by_identifier": "unit-test",
},
)
request_id = str((propose_payload.get("request") or {}).get("id") or "")
self.assertTrue(request_id)
pending_payload = execute_tool("memory.pending", {"user_id": self.user.id})
self.assertGreaterEqual(int(pending_payload.get("count") or 0), 1)
review_payload = execute_tool(
"memory.review",
{
"user_id": self.user.id,
"request_id": request_id,
"decision": "approve",
"reviewer_identifier": "admin",
},
)
memory_data = review_payload.get("memory") or {}
self.assertEqual("active", str(memory_data.get("status") or ""))
list_payload = execute_tool(
"memory.list",
{"user_id": self.user.id, "query": "status bullets"},
)
self.assertGreaterEqual(int(list_payload.get("count") or 0), 1)
def test_wiki_and_task_artifact_tools(self):
article_create = execute_tool(
"wiki.create_article",
{
"user_id": self.user.id,
"title": "MCP Integration Notes",
"markdown": "Initial setup steps.",
"related_task_id": str(self.task.id),
"tags": ["mcp", "memory"],
"status": "published",
},
)
article = article_create.get("article") or {}
self.assertEqual("mcp-integration-notes", str(article.get("slug") or ""))
article_update = execute_tool(
"wiki.update_article",
{
"user_id": self.user.id,
"article_id": str(article.get("id") or ""),
"markdown": "Updated setup steps.",
"approve_overwrite": True,
"summary": "Expanded usage instructions.",
},
)
revision = article_update.get("revision") or {}
self.assertEqual(2, int(revision.get("revision") or 0))
list_payload = execute_tool(
"wiki.list",
{"user_id": self.user.id, "query": "integration"},
)
self.assertEqual(1, int(list_payload.get("count") or 0))
get_payload = execute_tool(
"wiki.get",
{
"user_id": self.user.id,
"article_id": str(article.get("id") or ""),
"include_revisions": True,
},
)
self.assertGreaterEqual(len(get_payload.get("revisions") or []), 2)
note_payload = execute_tool(
"tasks.create_note",
{
"task_id": str(self.task.id),
"user_id": self.user.id,
"note": "Implemented wiki tooling.",
},
)
self.assertEqual("progress", str((note_payload.get("event") or {}).get("event_type")))
artifact_payload = execute_tool(
"tasks.link_artifact",
{
"task_id": str(self.task.id),
"user_id": self.user.id,
"kind": "wiki",
"path": "artifacts/wiki/mcp-integration-notes.md",
"summary": "Reference docs",
},
)
self.assertTrue(str((artifact_payload.get("artifact") or {}).get("id") or ""))
task_payload = execute_tool(
"tasks.get",
{"task_id": str(self.task.id), "user_id": self.user.id},
)
self.assertGreaterEqual(len(task_payload.get("artifact_links") or []), 1)
self.assertGreaterEqual(len(task_payload.get("knowledge_articles") or []), 1)
def test_docs_append_run_note_writes_file(self):
target = Path("/tmp/gia-mcp-test-notes.md")
if target.exists():
target.unlink()
payload = execute_tool(
"docs.append_run_note",
{
"title": "MCP Integration",
"content": "Connected manticore memory tools.",
"task_id": str(self.task.id),
"path": str(target),
},
)
self.assertTrue(payload.get("ok"))
content = target.read_text(encoding="utf-8")
self.assertIn("MCP Integration", content)
self.assertIn("Connected manticore memory tools.", content)
target.unlink()
def test_audit_logs_record_success_and_failures(self):
execute_tool("tasks.list", {"user_id": self.user.id})
with self.assertRaises(ValueError):
execute_tool("tasks.search", {"user_id": self.user.id})
success_row = MCPToolAuditLog.objects.filter(
tool_name="tasks.list",
ok=True,
).first()
self.assertIsNotNone(success_row)
failure_row = MCPToolAuditLog.objects.filter(
tool_name="tasks.search",
ok=False,
).first()
self.assertIsNotNone(failure_row)

View File

@@ -0,0 +1,97 @@
from __future__ import annotations
from datetime import timedelta
from io import StringIO
from django.core.management import call_command
from django.test import TestCase
from django.utils import timezone
from core.models import MemoryChangeRequest, MemoryItem, MessageEvent, User, WorkspaceConversation
class MemoryPipelineCommandTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username="memory-pipeline-user",
email="memory-pipeline@example.com",
password="pw",
)
self.conversation = WorkspaceConversation.objects.create(
user=self.user,
platform_type="signal",
title="Pipeline Scope",
platform_thread_id="pipeline-thread-1",
)
def test_memory_suggest_from_messages_creates_pending_request(self):
MessageEvent.objects.create(
user=self.user,
conversation=self.conversation,
ts=1700000000000,
direction="in",
text="I prefer short status updates and bullet points.",
source_system="signal",
)
out = StringIO()
call_command(
"memory_suggest_from_messages",
user_id=str(self.user.id),
limit_messages=50,
max_items=10,
stdout=out,
)
rendered = out.getvalue()
self.assertIn("memory-suggest-from-messages", rendered)
self.assertGreaterEqual(MemoryItem.objects.filter(user=self.user).count(), 1)
self.assertGreaterEqual(
MemoryChangeRequest.objects.filter(user=self.user, status="pending").count(),
1,
)
def test_memory_hygiene_expires_and_detects_contradictions(self):
expired = MemoryItem.objects.create(
user=self.user,
conversation=self.conversation,
memory_kind="fact",
status="active",
content={"field": "likes", "text": "calls in the evening"},
confidence_score=0.6,
expires_at=timezone.now() - timedelta(days=1),
)
MemoryItem.objects.create(
user=self.user,
conversation=self.conversation,
memory_kind="fact",
status="active",
content={"field": "timezone", "text": "UTC+1"},
confidence_score=0.7,
)
MemoryItem.objects.create(
user=self.user,
conversation=self.conversation,
memory_kind="fact",
status="active",
content={"field": "timezone", "text": "UTC-5"},
confidence_score=0.7,
)
out = StringIO()
call_command(
"memory_hygiene",
user_id=str(self.user.id),
stdout=out,
)
rendered = out.getvalue()
self.assertIn("memory-hygiene", rendered)
expired.refresh_from_db()
self.assertEqual("deprecated", expired.status)
contradiction_requests = MemoryChangeRequest.objects.filter(
user=self.user,
status="pending",
action="update",
reason__icontains="Contradiction",
).count()
self.assertGreaterEqual(contradiction_requests, 1)

View File

@@ -5,7 +5,7 @@ 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
from core.views.compose import _compose_availability_payload, _context_base
class PresenceQueryAndComposeContextTests(TestCase):
@@ -48,3 +48,30 @@ class PresenceQueryAndComposeContextTests(TestCase):
)
self.assertIsNotNone(base["person_identifier"])
self.assertEqual(str(self.person.id), str(base["person"].id))
def test_compose_availability_payload_falls_back_to_cross_service(self):
ts_value = now_ms()
record_native_signal(
AvailabilitySignal(
user=self.user,
person=self.person,
person_identifier=self.identifier,
service="whatsapp",
source_kind="message_in",
availability_state="available",
confidence=0.9,
ts=ts_value,
)
)
enabled, slices, summary = _compose_availability_payload(
user=self.user,
person=self.person,
service="signal",
range_start=ts_value - 60000,
range_end=ts_value + 60000,
)
self.assertTrue(enabled)
self.assertGreaterEqual(len(slices), 1)
self.assertEqual("whatsapp", str(slices[0].get("service")))
self.assertEqual("available", str(summary.get("state")))
self.assertTrue(bool(summary.get("is_cross_service")))

View File

@@ -124,3 +124,66 @@ class ReactionNormalizationTests(TestCase):
self.assertEqual("❤️", serialized["text"])
self.assertEqual([], list(serialized.get("reactions") or []))
self.assertEqual(str(anchor.id), serialized["reply_to_id"])
def test_apply_message_edit_tracks_history_and_updates_text(self):
message = Message.objects.create(
user=self.user,
session=self.session,
ts=1700000004000,
sender_uuid="author-3",
text="before",
source_service="signal",
source_message_id="1700000004000",
)
updated = async_to_sync(history.apply_message_edit)(
self.user,
self.identifier,
target_ts=1700000004000,
new_text="after",
source_service="signal",
actor="+15550000000",
payload={"origin": "test"},
)
self.assertIsNotNone(updated)
message.refresh_from_db()
self.assertEqual("after", str(message.text or ""))
edit_history = list((message.receipt_payload or {}).get("edit_history") or [])
self.assertEqual(1, len(edit_history))
self.assertEqual("before", str(edit_history[0].get("previous_text") or ""))
self.assertEqual("after", str(edit_history[0].get("new_text") or ""))
def test_serialize_message_marks_deleted_and_preserves_edit_history(self):
message = Message.objects.create(
user=self.user,
session=self.session,
ts=1700000005000,
sender_uuid="author-4",
text="keep me",
source_service="signal",
source_message_id="1700000005000",
receipt_payload={
"edit_history": [
{
"edited_ts": 1700000005100,
"source_service": "signal",
"actor": "+15550000000",
"previous_text": "old",
"new_text": "keep me",
}
],
"is_deleted": True,
"deleted": {
"deleted_ts": 1700000005200,
"source_service": "signal",
"actor": "+15550000000",
},
},
)
serialized = _serialize_message(message)
self.assertTrue(bool(serialized.get("is_deleted")))
self.assertEqual("(message deleted)", str(serialized.get("display_text") or ""))
self.assertEqual(1, int(serialized.get("edit_count") or 0))
self.assertEqual(1, len(list(serialized.get("edit_history") or [])))

View File

@@ -267,6 +267,76 @@ class SignalInboundReplyLinkTests(TransactionTestCase):
"Expected sync reaction to be applied via destination-number fallback resolution.",
)
def test_process_raw_inbound_event_applies_edit(self):
fake_ur = Mock()
fake_ur.message_received = AsyncMock(return_value=None)
fake_ur.xmpp = Mock()
fake_ur.xmpp.client = Mock()
fake_ur.xmpp.client.apply_external_reaction = AsyncMock(return_value=None)
client = SignalClient.__new__(SignalClient)
client.service = "signal"
client.ur = fake_ur
client.log = Mock()
client.client = Mock()
client.client.bot_uuid = ""
client.client.phone_number = ""
client._resolve_signal_identifiers = AsyncMock(return_value=[self.identifier])
client._auto_link_single_user_signal_identifier = AsyncMock(return_value=[])
payload = {
"envelope": {
"sourceNumber": "+15550002000",
"sourceUuid": "756078fd-d447-426d-a620-581a86d64f51",
"timestamp": 1772545466000,
"dataMessage": {
"editMessage": {
"targetSentTimestamp": 1772545458187,
"dataMessage": {"message": "anchor edited"},
}
},
}
}
async_to_sync(client._process_raw_inbound_event)(json.dumps(payload))
self.anchor.refresh_from_db()
self.assertEqual("anchor edited", str(self.anchor.text or ""))
edits = list((self.anchor.receipt_payload or {}).get("edit_history") or [])
self.assertEqual(1, len(edits))
def test_process_raw_inbound_event_applies_delete_tombstone_flag(self):
fake_ur = Mock()
fake_ur.message_received = AsyncMock(return_value=None)
fake_ur.xmpp = Mock()
fake_ur.xmpp.client = Mock()
fake_ur.xmpp.client.apply_external_reaction = AsyncMock(return_value=None)
client = SignalClient.__new__(SignalClient)
client.service = "signal"
client.ur = fake_ur
client.log = Mock()
client.client = Mock()
client.client.bot_uuid = ""
client.client.phone_number = ""
client._resolve_signal_identifiers = AsyncMock(return_value=[self.identifier])
client._auto_link_single_user_signal_identifier = AsyncMock(return_value=[])
payload = {
"envelope": {
"sourceNumber": "+15550002000",
"sourceUuid": "756078fd-d447-426d-a620-581a86d64f51",
"timestamp": 1772545467000,
"dataMessage": {
"delete": {
"targetSentTimestamp": 1772545458187,
}
},
}
}
async_to_sync(client._process_raw_inbound_event)(json.dumps(payload))
self.anchor.refresh_from_db()
self.assertTrue(bool((self.anchor.receipt_payload or {}).get("is_deleted")))
self.assertTrue(bool((self.anchor.receipt_payload or {}).get("deleted") or {}))
class SignalRuntimeCommandWritebackTests(TestCase):
def setUp(self):

View File

@@ -1,5 +1,7 @@
from asgiref.sync import async_to_sync
from django.test import SimpleTestCase
from core.clients import transport
from core.transports.capabilities import capability_snapshot, supports, unsupported_reason
@@ -15,3 +17,11 @@ class TransportCapabilitiesTests(SimpleTestCase):
snapshot = capability_snapshot()
self.assertIn("schema_version", snapshot)
self.assertIn("services", snapshot)
def test_transport_send_fails_fast_when_unsupported(self):
result = async_to_sync(transport.send_message_raw)(
"xmpp",
"person@example.com",
text="hello",
)
self.assertFalse(result)