feat: real ERPNext Healthcare integration + setup tooling
- Replace all mock tools with real ERPNext Healthcare operations - ERPNextHealthcare class: patients, practitioners, appointments, schedules - check_availability queries real practitioner schedules from ERPNext - create_appointment finds/creates patient + validates conflicts + books in ERPNext - Add /api/v1/config/test endpoint to validate all service connections - Add scripts/validate_setup.py for CLI validation of Meta/OpenAI/ERPNext/DB - Add scripts/seed_knowledge.py with full SKEEN catalog (services, products, packages, FAQ) - Add tests for webhook, health, and WhatsApp client - Update main.py to include config router
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
24
tests/test_health.py
Normal file
24
tests/test_health.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Tests for health check endpoints."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.main import create_app
|
||||
|
||||
|
||||
class TestHealth:
|
||||
def test_health_check(self):
|
||||
app = create_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "healthy"
|
||||
assert "timestamp" in data
|
||||
|
||||
def test_ready_check(self):
|
||||
app = create_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/ready")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "database" in data
|
||||
124
tests/test_webhook.py
Normal file
124
tests/test_webhook.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Tests for WhatsApp webhook endpoints."""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.main import create_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Create test client."""
|
||||
app = create_app()
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class TestWebhookVerification:
|
||||
"""Test GET /webhooks/whatsapp verification endpoint."""
|
||||
|
||||
def test_verify_subscription_success(self, client, monkeypatch):
|
||||
"""Successful webhook verification returns challenge."""
|
||||
monkeypatch.setenv("META_WEBHOOK_VERIFY_TOKEN", "test-token")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/webhooks/whatsapp",
|
||||
params={
|
||||
"hub.mode": "subscribe",
|
||||
"hub.verify_token": "test-token",
|
||||
"hub.challenge": "123456789",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.text == "123456789"
|
||||
|
||||
def test_verify_subscription_invalid_token(self, client, monkeypatch):
|
||||
"""Invalid token returns 403."""
|
||||
monkeypatch.setenv("META_WEBHOOK_VERIFY_TOKEN", "test-token")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/webhooks/whatsapp",
|
||||
params={
|
||||
"hub.mode": "subscribe",
|
||||
"hub.verify_token": "wrong-token",
|
||||
"hub.challenge": "123456789",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestWebhookReceive:
|
||||
"""Test POST /webhooks/whatsapp message reception."""
|
||||
|
||||
def test_receive_text_message(self, client):
|
||||
"""Process incoming text message."""
|
||||
payload = {
|
||||
"object": "whatsapp_business_account",
|
||||
"entry": [{
|
||||
"id": "WHATSAPP_BUSINESS_ACCOUNT_ID",
|
||||
"changes": [{
|
||||
"value": {
|
||||
"messaging_product": "whatsapp",
|
||||
"metadata": {
|
||||
"display_phone_number": "16505551111",
|
||||
"phone_number_id": "123456789",
|
||||
},
|
||||
"contacts": [{
|
||||
"profile": {"name": "Test User"},
|
||||
"wa_id": "5216641234567",
|
||||
}],
|
||||
"messages": [{
|
||||
"from": "5216641234567",
|
||||
"id": "wamid.TEST123",
|
||||
"timestamp": "1234567890",
|
||||
"text": {"body": "Hola, quiero agendar una cita"},
|
||||
"type": "text",
|
||||
}],
|
||||
},
|
||||
"field": "messages",
|
||||
}],
|
||||
}],
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/webhooks/whatsapp", json=payload)
|
||||
# In development, it processes synchronously
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] in ("processed", "no_messages")
|
||||
|
||||
def test_receive_status_update(self, client):
|
||||
"""Acknowledge status update without processing."""
|
||||
payload = {
|
||||
"object": "whatsapp_business_account",
|
||||
"entry": [{
|
||||
"id": "WHATSAPP_BUSINESS_ACCOUNT_ID",
|
||||
"changes": [{
|
||||
"value": {
|
||||
"messaging_product": "whatsapp",
|
||||
"metadata": {
|
||||
"display_phone_number": "16505551111",
|
||||
"phone_number_id": "123456789",
|
||||
},
|
||||
"statuses": [{
|
||||
"id": "wamid.TEST123",
|
||||
"status": "delivered",
|
||||
"timestamp": "1234567890",
|
||||
"recipient_id": "5216641234567",
|
||||
}],
|
||||
},
|
||||
"field": "messages",
|
||||
}],
|
||||
}],
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/webhooks/whatsapp", json=payload)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "acknowledged"
|
||||
|
||||
def test_invalid_payload(self, client):
|
||||
"""Invalid payload should be ignored gracefully."""
|
||||
payload = {"object": "not_whatsapp"}
|
||||
|
||||
response = client.post("/api/v1/webhooks/whatsapp", json=payload)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ignored"
|
||||
61
tests/test_whatsapp_client.py
Normal file
61
tests/test_whatsapp_client.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Tests for WhatsApp API client."""
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
from httpx import Response
|
||||
|
||||
from src.infrastructure.whatsapp.client import WhatsAppClient
|
||||
|
||||
|
||||
class TestWhatsAppClient:
|
||||
"""Test Meta WhatsApp Business API client."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
return WhatsAppClient()
|
||||
|
||||
@respx.mock
|
||||
async def test_send_text_message(self, client):
|
||||
"""Successfully send text message."""
|
||||
route = respx.post(
|
||||
"https://graph.facebook.com/v18.0/123456789012345/messages"
|
||||
).mock(return_value=Response(200, json={
|
||||
"messages": [{"id": "wamid.sent123"}],
|
||||
"contacts": [{"wa_id": "5216641234567"}],
|
||||
}))
|
||||
|
||||
result = await client.send_text_message("5216641234567", "Hola SKEEN")
|
||||
assert result["messages"][0]["id"] == "wamid.sent123"
|
||||
assert route.called
|
||||
|
||||
@respx.mock
|
||||
async def test_send_text_message_too_long(self, client):
|
||||
"""Truncate text over 4096 chars."""
|
||||
respx.post(
|
||||
"https://graph.facebook.com/v18.0/123456789012345/messages"
|
||||
).mock(return_value=Response(200, json={"messages": [{"id": "x"}]}))
|
||||
|
||||
long_text = "A" * 5000
|
||||
await client.send_text_message("5216641234567", long_text)
|
||||
# Should not raise
|
||||
|
||||
@respx.mock
|
||||
async def test_mark_as_read(self, client):
|
||||
"""Mark message as read."""
|
||||
route = respx.post(
|
||||
"https://graph.facebook.com/v18.0/123456789012345/messages"
|
||||
).mock(return_value=Response(200, json={"success": True}))
|
||||
|
||||
result = await client.mark_as_read("wamid.test123")
|
||||
assert result["success"] is True
|
||||
assert route.called
|
||||
|
||||
async def test_button_limit(self, client):
|
||||
"""More than 3 buttons raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Maximum 3 buttons"):
|
||||
await client.send_interactive_buttons(
|
||||
"5216641234567",
|
||||
"Choose:",
|
||||
[{"id": "1", "title": "A"}, {"id": "2", "title": "B"},
|
||||
{"id": "3", "title": "C"}, {"id": "4", "title": "D"}],
|
||||
)
|
||||
Reference in New Issue
Block a user