diff --git a/platform/inneros_core_runtime/notifications/ops_task_alerts.py b/platform/inneros_core_runtime/notifications/ops_task_alerts.py index fd976804..18c8e592 100644 --- a/platform/inneros_core_runtime/notifications/ops_task_alerts.py +++ b/platform/inneros_core_runtime/notifications/ops_task_alerts.py @@ -104,6 +104,7 @@ def _notify_ops_auth_email_fallback( body: str, provider: str, tid: str, + alert_kind: str = "external_spend", ) -> dict[str, Any]: """When Evolution/WhatsApp is down, reach owner via email (same CONFIRMAR/CANCELAR semantics).""" import os @@ -120,15 +121,18 @@ def _notify_ops_auth_email_fallback( if not to_addr: return {"ok": False, "skipped": "no_owner_email_configured"} short = tid.replace("ops_", "")[:6] - subject = f"[RalfIA OPS] Autorización {provider.upper()} · {short}" - email_body = ( - f"{body}\n\n" - f"---\n" - f"WhatsApp no disponible; responde a este correo o usa MCP:\n" - f"• CONFIRMAR {short}\n" - f"• CANCELAR {short}\n" - f'• cursor_authorize_ops_task(task_id="{tid}")\n' + needs_approval = alert_kind == "external_spend" + subject = ( + f"[RalfIA OPS] Autorización {provider.upper()} · {short}" + if needs_approval else f"[RalfIA OPS] Alerta interna {provider.upper()} · {short}" ) + instructions = ( + f"Autorización pendiente para tarea EXACTA: {tid}. Usa MCP/WhatsApp autenticado cuando esté disponible.\n" + f"NO se procesa automáticamente una respuesta a este correo.\n" + if needs_approval else + f"Alerta informativa, tarea {tid}. No se requiere autorización de gasto externo.\n" + ) + email_body = f"{body}\n\n---\nWhatsApp no disponible.\n{instructions}" from inneros_core_runtime.notifications.email_client import send_email sent = send_email( @@ -181,17 +185,14 @@ def notify_ops_owner_authorization_request( if not NOTIFY_OPS_TASKS or not NOTIFY_OPS_CREATE_AUTH: return {"ok": False, "skipped": "notifications_disabled"} + # WhatsApp unavailable must NOT return before the SMTP fallback. + # A connection API failure is also treated as unavailable, not healthy. try: from raphiia_openai.notifications.evolution_client import any_whatsapp_connected - if not any_whatsapp_connected(): - return { - "ok": False, - "skipped": "evolution_whatsapp_down", - "task_id": str(task.get("task_id") or ""), - } + whatsapp_connected = bool(any_whatsapp_connected()) except Exception: - pass + whatsapp_connected = False skip = _synthetic_ops_task_skip(task) if skip: @@ -286,17 +287,31 @@ def notify_ops_owner_authorization_request( from raphiia_openai.notifications.evolution_client import send_alert_whatsapp, send_whatsapp_interactive if alert_kind != "external_spend": - result = send_alert_whatsapp(body, prefix_node=True) + result = (send_alert_whatsapp(body, prefix_node=True) if whatsapp_connected + else {"ok": False, "reason": "evolution_whatsapp_down"}) + email_fallback = None + # Email for blocked/internal-fix or explicitly urgent internal tasks, + # not a flood for every ordinary internal-ready notification. + urgent = str(task.get("priority") or "").lower() in {"p0", "urgent", "critical", "high"} + if not result.get("ok") and (alert_kind == "internal_fix" or urgent): + email_fallback = _notify_ops_auth_email_fallback( + task, body=body, provider=provider, tid=tid, + alert_kind=alert_kind, + ) + if email_fallback.get("ok"): + result = {"ok": True, "channel": "email_fallback", "email": email_fallback} if result.get("ok"): _mark_sent("owner_auth", tid) + _mark_owner_auth_sent_mongo(tid) return { "ok": bool(result.get("ok")), "result": result, "task_id": tid, "summary": summary, - "delivery_mode": "text_internal_fix" if alert_kind == "internal_fix" else "text_internal_brief", + "delivery_mode": ("email_fallback" if email_fallback and email_fallback.get("ok") + else "text_internal_fix" if alert_kind == "internal_fix" else "text_internal_brief"), "poll_message_id": "", - "email_fallback": None, + "email_fallback": email_fallback, } from inneros_core_runtime import whatsapp_ops_auth_pending as ops_pending @@ -310,7 +325,10 @@ def notify_ops_owner_authorization_request( {"id": f"ops.auth.yes.{tid}", "label": "Confirmar"}, {"id": f"ops.auth.no.{tid}", "label": "Cancelar"}, ] - if auth_ui == "poll": + if not whatsapp_connected: + result = {"ok": False, "reason": "evolution_whatsapp_down"} + delivery_mode = "email_fallback" + elif auth_ui == "poll": from raphiia_openai.notifications.evolution_client import ( OPS_AUTH_POLL_NO, OPS_AUTH_POLL_YES, @@ -341,7 +359,7 @@ def notify_ops_owner_authorization_request( provider=provider, poll_message_id=poll_message_id or None, ) - if not result.get("ok"): + if not result.get("ok") and whatsapp_connected: result = send_alert_whatsapp(body, prefix_node=True) delivery_mode = "text" email_fallback = None @@ -365,6 +383,27 @@ def notify_ops_owner_authorization_request( } + +def resend_ops_owner_authorization(task_id: str) -> dict[str, Any]: + """Resend one existing, nonterminal OPS challenge; never create a new task.""" + import re + + tid = str(task_id or "").strip().lower() + if not re.fullmatch(r"ops_[a-f0-9]{12}", tid): + return {"ok": False, "skipped": "invalid_exact_task_id", "task_id": tid} + try: + from raphiia_openai import mongo_store + from inneros_core_runtime.coordination_live import OPS_TASKS_COL + + task = mongo_store.get_db()[OPS_TASKS_COL].find_one({"task_id": tid}, {"_id": 0}) + except Exception as exc: + return {"ok": False, "skipped": "task_lookup_failed", "error": type(exc).__name__, "task_id": tid} + if not task: + return {"ok": False, "skipped": "task_not_found", "task_id": tid} + if str(task.get("status") or "").lower() in TERMINAL_OPS: + return {"ok": False, "skipped": "task_terminal", "task_id": tid} + return notify_ops_owner_authorization_request(task, force=True) + _AWAITING_CLAIM_STATUSES = frozenset( { "awaiting_cursor_claim", @@ -397,6 +436,15 @@ def notify_cursor_awaiting_claim(task: dict[str, Any], *, previous_status: str | return {"ok": False, "skipped": "no_status_change"} tid = str(task.get("task_id") or "") + # A persistent pending WhatsApp challenge outranks the volatile cooldown + # ledger. Do not nag the owner while Confirmar/Cancelar is unanswered. + try: + from inneros_core_runtime import whatsapp_ops_auth_pending as ops_pending + pending = ops_pending.get_by_task_id(tid) + if pending and pending.get("status") in {"pending", "cancel_requested"}: + return {"ok": False, "skipped": "already_notified_on_create", "task_id": tid} + except Exception: + pass if owner_auth_notified(tid): return {"ok": False, "skipped": "already_notified_on_create", "task_id": tid} @@ -433,10 +481,30 @@ def notify_cursor_awaiting_claim(task: dict[str, Any], *, previous_status: str | if not _can_send("cursor_claim", key): return {"ok": False, "skipped": "cooldown_or_dedupe"} - result = send_alert_whatsapp(body, prefix_node=True) + try: + from raphiia_openai.notifications.evolution_client import any_whatsapp_connected + + whatsapp_connected = bool(any_whatsapp_connected()) + except Exception: + whatsapp_connected = False + result = (send_alert_whatsapp(body, prefix_node=True) if whatsapp_connected + else {"ok": False, "reason": "evolution_whatsapp_down"}) + email_fallback = None + if not result.get("ok"): + # Task already exists, so this is notification only, never a new claim. + email_fallback = _notify_ops_auth_email_fallback( + task, body=body, provider=provider, tid=tid, + alert_kind="external_spend", + ) + if email_fallback.get("ok"): + result = {"ok": True, "channel": "email_fallback", "email": email_fallback} if result.get("ok"): _mark_sent("cursor_claim", key) - return {"ok": bool(result.get("ok")), "result": result, "task_id": task.get("task_id"), "status": status} + return { + "ok": bool(result.get("ok")), "result": result, + "task_id": task.get("task_id"), "status": status, + "email_fallback": email_fallback, + } def notify_ops_transition( diff --git a/platform/inneros_core_runtime/ops_whatsapp_owner.py b/platform/inneros_core_runtime/ops_whatsapp_owner.py index dcfc710b..41084ff0 100644 --- a/platform/inneros_core_runtime/ops_whatsapp_owner.py +++ b/platform/inneros_core_runtime/ops_whatsapp_owner.py @@ -120,24 +120,57 @@ def handle_owner_reply( if NUMERIC_AUTH_RE.match(text) and phone: from inneros_core_runtime import whatsapp_ops_auth_pending as ops_pending - pending = ops_pending.latest_pending(phone) - if pending: - tid = str(pending.get("task_id") or "") - text = f"{'SI' if text == '1' else 'NO'} {tid}" + # Numeric 1/2 has no task identity. Refuse it when multiple pending + # requests exist rather than authorize whichever happened to be newest. + pending_tasks = ops_pending.pending_for_phone(phone, limit=2) + if len(pending_tasks) != 1: + return { + "ok": False, "action": "ambiguous_numeric_auth", + "text": "No puedo identificar la aprobación. Usa el botón de la tarea exacta o responde CONFIRMAR ops_.", + } + tid = str(pending_tasks[0].get("task_id") or "") + if not re.fullmatch(r"ops_[a-f0-9]{12}", tid): + return {"ok": False, "action": "ambiguous_numeric_auth", + "text": "Usa el botón de la tarea exacta con un ID válido."} + text = f"{'SI' if text == '1' else 'NO'} {tid}" deny = DENY_RE.match(text) if deny: tid = _resolve_task_id(deny.group(1)) or deny.group(1) + from datetime import datetime, timezone + from raphiia_openai import mongo_store + from inneros_core_runtime.coordination_live import OPS_TASKS_COL from inneros_core_runtime import durable_coordination_spine from inneros_core_runtime import whatsapp_ops_auth_pending as ops_pending + col = mongo_store.get_db()[OPS_TASKS_COL] + before = col.find_one({"task_id": tid}, {"_id": 0}) + if not before: + return {"ok": False, "text": f"Tarea {tid} no encontrada.", + "task_id": tid, "action": "deny_not_found"} + if str(before.get("status") or "").lower() in {"completed", "cancelled", "failed", "superseded"}: + return {"ok": False, "text": f"Tarea {tid} ya está cerrada.", + "task_id": tid, "action": "deny_closed"} out = durable_coordination_spine.signal_task_workflow(tid, "cancel", "owner_rejected_whatsapp") - ops_pending.clear_pending(tid) - return { - "ok": bool(out.get("ok")), - "text": f"❌ Acción cancelada · {tid}", - "task_id": tid, - "action": "deny", - } + if not out.get("ok"): + return {"ok": False, "text": f"No se pudo solicitar la cancelación de {tid}.", + "task_id": tid, "action": "deny_failed", "details": out} + # A Temporal signal is not durable confirmation of a terminal state. + col.update_one( + {"task_id": tid}, + {"$set": { + "cancellation_requested_at": datetime.now(timezone.utc).isoformat(), + "cancellation_requested_by": owner_actor, + "cancellation_channel": "whatsapp", + }}, + ) + after = col.find_one({"task_id": tid}, {"_id": 0}) + if str((after or {}).get("status") or "").lower() == "cancelled": + ops_pending.clear_pending(tid) + return {"ok": True, "text": f"❌ Acción cancelada · {tid}", + "task_id": tid, "action": "deny"} + ops_pending.mark_cancel_requested(tid) + return {"ok": True, "text": f"⏳ Cancelación solicitada · {tid}; pendiente de confirmación de Temporal.", + "task_id": tid, "action": "deny_pending"} if re.fullmatch(r"^(?:s[ií]|dale|ok|autorizo|autoriza)\.?$", text, re.I) and phone: from inneros_core_runtime import whatsapp_ops_auth_pending as ops_pending @@ -197,6 +230,9 @@ def handle_owner_reply( terminal = {"completed", "failed", "blocked", "partial", "cancelled", "superseded"} status_now = str(task.get("status") or "").lower() + if task.get("cancellation_requested_at") or status_now == "cancel_requested": + return {"ok": False, "action": "auth_cancel_pending", "task_id": tid, + "text": "Esta tarea tiene una cancelación pendiente; no se puede confirmar."} if status_now in terminal: return { "ok": False, diff --git a/platform/inneros_core_runtime/temporal_activities.py b/platform/inneros_core_runtime/temporal_activities.py index b2dfe9d4..9dd196f7 100644 --- a/platform/inneros_core_runtime/temporal_activities.py +++ b/platform/inneros_core_runtime/temporal_activities.py @@ -34,11 +34,16 @@ def _mongo_projection_client(): - """Use the same Mongo settings as MCP/coordination (not localhost defaults).""" + """Honor the isolated Temporal canary DB, without changing production defaults. + + The client comes from the canonical Mongo connector. An explicit + INNEROS_MONGO_DB selects the event-projection database for isolated tests. + """ from raphiia_openai import mongo_store db = mongo_store.get_db() - return db.client, db.name + selected_name = (os.getenv("INNEROS_MONGO_DB") or "").strip() + return db.client, selected_name or db.name WORKTREE_BASE = Path( os.environ.get( "INNEROS_WORKTREE_BASE", diff --git a/platform/tests/probe_ops_email_fallback_config_readonly.py b/platform/tests/probe_ops_email_fallback_config_readonly.py new file mode 100644 index 00000000..f1c10a02 --- /dev/null +++ b/platform/tests/probe_ops_email_fallback_config_readonly.py @@ -0,0 +1,20 @@ +"""Explicit-only read-only check of OPS email fallback settings, no secrets.""" +from __future__ import annotations + +import json +import os + + +def test_ops_email_fallback_config_report_only(): + fields = ["RALFIA_OPS_AUTH_EMAIL_TO", "INNEROS_ADMIN_EMAILS", + "NOTIFY_EMAIL_TO", "RALFIA_ALERTS_EMAIL_TO", + "NOTIFY_OPS_AUTH_EMAIL_FALLBACK", + "RALFIA_ALERTS_TO", "NOTIFY_WHATSAPP_TO"] + present = {name: bool(os.getenv(name, "").strip()) for name in fields} + active = os.getenv("NOTIFY_OPS_AUTH_EMAIL_FALLBACK", "1").strip().lower() not in {"0", "false", "off", "no"} + configured = any(present[k] for k in ["RALFIA_OPS_AUTH_EMAIL_TO", "INNEROS_ADMIN_EMAILS", "NOTIFY_EMAIL_TO"]) + print("OPS_EMAIL_FALLBACK_CONFIG=" + json.dumps({ + "configured_delivery_recipient": configured, + "fallback_enabled": active, + "env_presence_only": present, + }, sort_keys=True)) diff --git a/platform/tests/probe_owner_whatsapp_readonly.py b/platform/tests/probe_owner_whatsapp_readonly.py new file mode 100644 index 00000000..19e4eed5 --- /dev/null +++ b/platform/tests/probe_owner_whatsapp_readonly.py @@ -0,0 +1,24 @@ +"""Explicit-only owner WhatsApp connectivity preflight: GET requests, no send. + +Does not print phone numbers, API keys, instance IDs or server URLs. +""" +import json +import os + + +def test_owner_whatsapp_readonly_connectivity(): + from raphiia_openai.notifications import evolution_client as evo + + status = evo.dual_whatsapp_status() + safe = { + node: {"api_up": bool(info.get("api_up")), + "connected": bool(info.get("connected"))} + for node, info in status.items() + } + safe["owner_alert_destination_configured"] = bool( + (os.getenv("RALFIA_ALERTS_TO") or os.getenv("NOTIFY_WHATSAPP_TO") or "").strip() + ) + safe["any_connected"] = bool(evo.any_whatsapp_connected()) + safe["read_only"] = True + print("INNEROS_OWNER_WHATSAPP_PREFLIGHT=" + json.dumps(safe, sort_keys=True)) + assert safe["any_connected"], "No active WhatsApp Evolution session visible from test runner" diff --git a/platform/tests/test_ops_owner_authorization_notify.py b/platform/tests/test_ops_owner_authorization_notify.py index 3ea1c21c..59cd8c2b 100644 --- a/platform/tests/test_ops_owner_authorization_notify.py +++ b/platform/tests/test_ops_owner_authorization_notify.py @@ -44,3 +44,96 @@ def test_notify_ops_owner_authorization_request_cursor(): assert "OPS actual: ops_new1" in body assert "procede cursor" in body assert alerts.owner_auth_notified("ops_new1") + + +def _owner_task(*, priority="urgent"): + return { + "task_id": "ops_abcdef123456", + "human_task_id": "P0-WA-FALLBACK", + "title": "Revisar reparación del disco", + "assignee": "cursor", + "preferred_provider": "cursor", + "execution_lane": "cursor_interactive", + "do_not_auto_dispatch": True, + "priority": priority, + "repo": "Rafa-Innerchispa/innerops-agentic-platform", + } + + +def test_smtp_fallback_when_both_whatsapp_sessions_closed(): + from inneros_core_runtime.execution_binding import owner_execution_summary + + task = _owner_task() + alerts._STATE["sent"] = [] + alerts._STATE["cooldowns"] = {} + with ( + mock.patch("raphiia_openai.notifications.evolution_client.any_whatsapp_connected", + return_value=False), + mock.patch("inneros_core_runtime.execution_binding.owner_execution_summary", + return_value={"owner_whatsapp_alert_kind": "external_spend", + "provider": "cursor", + "why_not_internal": "credit gate", + "owner_action_hint": "review task", + "internal_alternative": ""}), + mock.patch("raphiia_openai.notifications.evolution_client.send_whatsapp_interactive") as interactive, + mock.patch("raphiia_openai.notifications.evolution_client.send_alert_whatsapp") as plain, + mock.patch.object(alerts, "_notify_ops_auth_email_fallback", + return_value={"ok": True, "channel": "email"}) as fallback, + mock.patch.object(alerts, "_mark_owner_auth_sent_mongo"), + ): + result = alerts.notify_ops_owner_authorization_request(task, force=True) + assert result["ok"] is True + assert result["delivery_mode"] == "email_fallback" + assert result["email_fallback"]["ok"] is True + fallback.assert_called_once() + assert fallback.call_args.kwargs["tid"] == task["task_id"] + interactive.assert_not_called() + plain.assert_not_called() + + +def test_urgent_internal_fix_uses_email_instead_of_silent_drop(): + task = _owner_task(priority="urgent") + alerts._STATE["sent"] = [] + alerts._STATE["cooldowns"] = {} + with ( + mock.patch("raphiia_openai.notifications.evolution_client.any_whatsapp_connected", + return_value=False), + mock.patch("inneros_core_runtime.execution_binding.owner_execution_summary", + return_value={"owner_whatsapp_alert_kind": "internal_fix", + "provider": "dev_swarm", + "why_not_internal": "repo contract incomplete", + "owner_action_hint": "", + "internal_alternative": ""}), + mock.patch("raphiia_openai.notifications.evolution_client.send_alert_whatsapp") as plain, + mock.patch.object(alerts, "_notify_ops_auth_email_fallback", + return_value={"ok": True, "channel": "email"}) as fallback, + mock.patch.object(alerts, "_mark_owner_auth_sent_mongo"), + ): + result = alerts.notify_ops_owner_authorization_request(task, force=True) + assert result["ok"] is True + assert result["delivery_mode"] == "email_fallback" + assert fallback.call_args.kwargs["alert_kind"] == "internal_fix" + plain.assert_not_called() + + +def test_fallback_email_does_not_claim_email_replies_are_authorized(): + task = _owner_task() + with ( + mock.patch.dict("os.environ", { + "NOTIFY_OPS_AUTH_EMAIL_FALLBACK": "1", + "RALFIA_OPS_AUTH_EMAIL_TO": "rafagye@gmail.com", + }), + mock.patch("inneros_core_runtime.notifications.email_client.send_email", + return_value={"ok": True}) as send, + ): + external = alerts._notify_ops_auth_email_fallback( + task, body="Prueba", provider="cursor", tid=task["task_id"]) + internal = alerts._notify_ops_auth_email_fallback( + task, body="Prueba", provider="dev_swarm", + tid=task["task_id"], alert_kind="internal_fix") + assert external["ok"] and internal["ok"] + body_external = send.call_args_list[0].kwargs["body"] + body_internal = send.call_args_list[1].kwargs["body"] + assert "NO se procesa automáticamente" in body_external + assert "CONFIRMAR" not in body_internal + assert "No se requiere autorización" in body_internal diff --git a/platform/tests/test_ops_task_alerts_cursor.py b/platform/tests/test_ops_task_alerts_cursor.py index 31f4fdc6..795c581c 100644 --- a/platform/tests/test_ops_task_alerts_cursor.py +++ b/platform/tests/test_ops_task_alerts_cursor.py @@ -27,7 +27,11 @@ def test_notify_cursor_awaiting_claim_sends_whatsapp(): } alerts._STATE["cooldowns"] = {} alerts._STATE["sent"] = [] - with mock.patch.object(alerts, "send_alert_whatsapp", return_value={"ok": True}) as send: + with ( + mock.patch("raphiia_openai.notifications.evolution_client.any_whatsapp_connected", + return_value=True), + mock.patch.object(alerts, "send_alert_whatsapp", return_value={"ok": True}) as send, + ): out = alerts.notify_cursor_awaiting_claim(task, previous_status="queued") assert out["ok"] is True send.assert_called_once() diff --git a/platform/tests/test_ops_whatsapp_authorization_flow.py b/platform/tests/test_ops_whatsapp_authorization_flow.py index 5de87f64..795a939d 100644 --- a/platform/tests/test_ops_whatsapp_authorization_flow.py +++ b/platform/tests/test_ops_whatsapp_authorization_flow.py @@ -145,3 +145,29 @@ def test_resend_exact_task_does_not_create_new_ops_task(): assert out["ok"] is True notify.assert_called_once_with(task, force=True) col.insert_one.assert_not_called() + + +def test_awaiting_claim_uses_email_when_both_whatsapp_sessions_are_down(): + task = { + "task_id": "ops_abcdef123456", + "status": "awaiting_cursor_claim", + "assignee": "cursor", + "preferred_provider": "cursor", + "title": "Diagnosticar disco", + } + alerts._STATE["cooldowns"] = {} + alerts._STATE["sent"] = [] + with ( + patch("inneros_core_runtime.execution_binding.resolve_execution_binding", return_value={}), + patch("inneros_core_runtime.execution_binding.requires_external_owner_authorization", return_value=True), + patch("inneros_core_runtime.whatsapp_ops_auth_pending.get_by_task_id", return_value=None), + patch.object(alerts, "owner_auth_notified", return_value=False), + patch("raphiia_openai.notifications.evolution_client.any_whatsapp_connected", return_value=False), + patch.object(alerts, "send_alert_whatsapp") as send, + patch.object(alerts, "_notify_ops_auth_email_fallback", return_value={"ok": True}) as fallback, + ): + out = alerts.notify_cursor_awaiting_claim(task, previous_status="queued") + assert out["ok"] is True + assert out["result"]["channel"] == "email_fallback" + assert fallback.call_args.kwargs["tid"] == "ops_abcdef123456" + send.assert_not_called() diff --git a/platform/tests/test_ops_whatsapp_internal_fix.py b/platform/tests/test_ops_whatsapp_internal_fix.py index f8dcd9a2..b22cfa2f 100644 --- a/platform/tests/test_ops_whatsapp_internal_fix.py +++ b/platform/tests/test_ops_whatsapp_internal_fix.py @@ -61,10 +61,12 @@ def test_internal_fix_whatsapp_no_credit_wording_no_confirm_buttons(): binding = eb.resolve_execution_binding(task) alerts._STATE["cooldowns"] = {} alerts._STATE["sent"] = [] - with mock.patch( - "raphiia_openai.notifications.evolution_client.send_alert_whatsapp", - return_value={"ok": True}, - ) as send: + with ( + mock.patch("raphiia_openai.notifications.evolution_client.any_whatsapp_connected", + return_value=True), + mock.patch("raphiia_openai.notifications.evolution_client.send_alert_whatsapp", + return_value={"ok": True}) as send, + ): out = alerts.notify_ops_owner_authorization_request(task, binding=binding, force=True) assert out["ok"] is True assert out["delivery_mode"] == "text_internal_fix" @@ -103,14 +105,16 @@ def test_notify_ops_owner_authorization_request_cursor_external_spend(): binding = eb.resolve_execution_binding(task) alerts._STATE["cooldowns"] = {} alerts._STATE["sent"] = [] - with mock.patch( - "raphiia_openai.notifications.evolution_client.send_whatsapp_interactive", - return_value={"ok": True}, - ) as send: - with mock.patch("inneros_core_runtime.whatsapp_ops_auth_pending.register_pending"): - out = alerts.notify_ops_owner_authorization_request( - task, binding=binding, source_agent="notion", force=True - ) + with ( + mock.patch("raphiia_openai.notifications.evolution_client.any_whatsapp_connected", + return_value=True), + mock.patch("raphiia_openai.notifications.evolution_client.send_whatsapp_interactive", + return_value={"ok": True}) as send, + mock.patch("inneros_core_runtime.whatsapp_ops_auth_pending.register_pending"), + ): + out = alerts.notify_ops_owner_authorization_request( + task, binding=binding, source_agent="notion", force=True + ) assert out["ok"] is True body = send.call_args[0][0] assert "Por qué no runner interno" in body diff --git a/platform/tests/test_temporal_canary_isolation_p0.py b/platform/tests/test_temporal_canary_isolation_p0.py index 2efe333c..0b8f0210 100644 --- a/platform/tests/test_temporal_canary_isolation_p0.py +++ b/platform/tests/test_temporal_canary_isolation_p0.py @@ -33,6 +33,35 @@ def test_mongo_database_uses_environment_override(self) -> None: "pcdoctor_swarm_canary", ) + def test_temporal_projection_client_honors_canary_db_override(self) -> None: + from unittest.mock import MagicMock + + from inneros_core_runtime import temporal_activities + fake_db = MagicMock() + fake_db.name = "pcdoctor_swarm" + fake_client = object() + fake_db.client = fake_client + with ( + patch("raphiia_openai.mongo_store.get_db", return_value=fake_db), + patch.dict(os.environ, {"INNEROS_MONGO_DB": "pcdoctor_swarm_canary"}), + ): + client, db_name = temporal_activities._mongo_projection_client() + self.assertIs(client, fake_client) + self.assertEqual(db_name, "pcdoctor_swarm_canary") + + def test_temporal_projection_client_preserves_prod_name_without_override(self) -> None: + from unittest.mock import MagicMock + + from inneros_core_runtime import temporal_activities + fake_db = MagicMock() + fake_db.name = "pcdoctor_swarm" + with ( + patch("raphiia_openai.mongo_store.get_db", return_value=fake_db), + patch.dict(os.environ, {"INNEROS_MONGO_DB": ""}), + ): + _, db_name = temporal_activities._mongo_projection_client() + self.assertEqual(db_name, "pcdoctor_swarm") + def test_mongo_event_sink_has_no_fixed_database(self) -> None: sink = durable_coordination_spine.MongoEventSink() self.assertEqual(sink.db_name, "")