feat: initial commit — HermesMessages SaaS platform

Backend (FastAPI + Python 3.12):
- Multi-tenant auth with JWT: login, register, refresh, Meta OAuth
- Business & BusinessConfig management
- WhatsApp webhook with HMAC signature verification
- Bot engine powered by Claude AI
- Calendar availability with Redis caching
- Reservations CRUD with status management
- Dashboard analytics (stats, agenda, peak hours)
- Billing & plan management
- Admin panel with platform-wide stats
- Async bcrypt via asyncio.to_thread
- IntegrityError handling for concurrent registration race conditions

Frontend (React 18 + Vite + Tailwind CSS):
- Multi-step guided registration form with helper text on every field
- Login page with show/hide password toggle
- Protected routes with AuthContext
- Dashboard with stats cards, bar chart, and daily agenda
- Reservations list with search, filters, and inline status actions
- Calendar with weekly view, slot availability, and date blocking
- Config page: business info, schedules, bot personality
- Billing page with plan comparison and usage bar

Design system:
- Bricolage Grotesque + DM Sans typography
- Emerald primary palette with semantic color tokens
- scale(0.97) button press feedback, ease-out animations
- Skeleton loaders, stagger animations, prefers-reduced-motion support
- Accessible: aria-labels, visible focus rings, 4.5:1 contrast

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 09:49:41 -05:00
commit 798bd14312
95 changed files with 5836 additions and 0 deletions

View File

115
backend/tests/conftest.py Normal file
View File

@ -0,0 +1,115 @@
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from unittest.mock import AsyncMock
from app.core.database import Base, get_db
from app.core.redis import get_redis
from app.main import app
# Importar todos los modelos para que Base.metadata los registre
from app.modules.auth.models import User # noqa
from app.modules.business.models import Business, BusinessConfig # noqa
from app.modules.reservations.models import Reservation # noqa
TEST_DB_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/hermesmessages_test"
@pytest_asyncio.fixture(scope="session")
async def engine():
_engine = create_async_engine(TEST_DB_URL)
async with _engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
yield _engine
async with _engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await _engine.dispose()
@pytest_asyncio.fixture
async def db(engine) -> AsyncSession:
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
yield session
await session.rollback()
@pytest_asyncio.fixture
def redis_mock() -> AsyncMock:
mock = AsyncMock()
mock.get = AsyncMock(return_value=None)
mock.setex = AsyncMock()
mock.delete = AsyncMock()
mock.aclose = AsyncMock()
return mock
@pytest_asyncio.fixture
async def client(db: AsyncSession, redis_mock: AsyncMock) -> AsyncClient:
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[get_redis] = lambda: redis_mock
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
app.dependency_overrides.clear()
@pytest_asyncio.fixture
async def client_no_db(redis_mock: AsyncMock) -> AsyncClient:
"""Cliente HTTP sin fixture de base de datos — para endpoints que no usan DB."""
app.dependency_overrides[get_redis] = lambda: redis_mock
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
app.dependency_overrides.clear()
# --- Helpers para crear datos de prueba ---
async def make_business(db: AsyncSession, **kwargs):
from app.modules.business.models import Business, BusinessConfig
from datetime import time
business = Business(
name=kwargs.get("name", "Restaurante Test"),
type=kwargs.get("type", "restaurant"),
timezone="UTC",
status=kwargs.get("status", "active"),
plan=kwargs.get("plan", "basic"),
whatsapp_phone_number_id=kwargs.get("phone_number_id", "12345"),
whatsapp_access_token=kwargs.get("access_token", "test-token"),
)
db.add(business)
await db.flush()
config = BusinessConfig(
business_id=business.id,
open_days=[0, 1, 2, 3, 4], # lunes a viernes
open_time=time(9, 0),
close_time=time(18, 0),
slot_duration=60,
max_per_slot=1,
blocked_dates=[],
assistant_name="Hermes",
tone="formal",
)
db.add(config)
await db.commit()
await db.refresh(business)
return business
async def make_user(db: AsyncSession, business_id: int, **kwargs):
from app.modules.auth.models import User
from app.core.security import hash_password
user = User(
business_id=business_id,
email=kwargs.get("email", "owner@test.com"),
hashed_password=hash_password(kwargs.get("password", "secret123")),
role=kwargs.get("role", "owner"),
)
db.add(user)
await db.commit()
await db.refresh(user)
return user

164
backend/tests/test_auth.py Normal file
View File

@ -0,0 +1,164 @@
import pytest
from httpx import AsyncClient
from tests.conftest import make_business, make_user
REGISTER_PAYLOAD = {
"business_name": "Restaurante Nuevo",
"business_type": "restaurant",
"timezone": "America/Bogota",
"email": "nuevo@negocio.com",
"password": "segura123",
}
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
async def test_register_creates_business_user_and_returns_token(client: AsyncClient):
response = await client.post("/auth/register", json=REGISTER_PAYLOAD)
assert response.status_code == 201
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
assert isinstance(data["business_id"], int)
assert isinstance(data["user_id"], int)
async def test_register_token_grants_access_to_own_business(client: AsyncClient):
response = await client.post("/auth/register", json=REGISTER_PAYLOAD | {"email": "owner2@test.com"})
token = response.json()["access_token"]
business_id = response.json()["business_id"]
me = await client.get("/business/me", headers={"Authorization": f"Bearer {token}"})
assert me.status_code == 200
assert me.json()["id"] == business_id
assert me.json()["name"] == REGISTER_PAYLOAD["business_name"]
async def test_register_duplicate_email_returns_409(client: AsyncClient):
payload = REGISTER_PAYLOAD | {"email": "dup@test.com"}
first = await client.post("/auth/register", json=payload)
assert first.status_code == 201
second = await client.post("/auth/register", json=payload)
assert second.status_code == 409
async def test_register_password_too_short_returns_422(client: AsyncClient):
response = await client.post("/auth/register", json=REGISTER_PAYLOAD | {"email": "short@test.com", "password": "abc"})
assert response.status_code == 422
async def test_register_empty_business_name_returns_422(client: AsyncClient):
response = await client.post("/auth/register", json=REGISTER_PAYLOAD | {"email": "empty@test.com", "business_name": " "})
assert response.status_code == 422
async def test_register_creates_default_business_config(client: AsyncClient):
response = await client.post("/auth/register", json=REGISTER_PAYLOAD | {"email": "config@test.com"})
token = response.json()["access_token"]
config = await client.get("/business/me/config", headers={"Authorization": f"Bearer {token}"})
assert config.status_code == 200
data = config.json()
assert data["open_days"] == [0, 1, 2, 3, 4]
assert data["slot_duration"] == 60
assert data["assistant_name"] == "Hermes"
async def test_two_businesses_cannot_see_each_other_reservations(client: AsyncClient):
r1 = await client.post("/auth/register", json=REGISTER_PAYLOAD | {"email": "biz1@test.com"})
r2 = await client.post("/auth/register", json=REGISTER_PAYLOAD | {"email": "biz2@test.com"})
token1 = r1.json()["access_token"]
token2 = r2.json()["access_token"]
res1 = await client.get("/reservations/", headers={"Authorization": f"Bearer {token1}"})
res2 = await client.get("/reservations/", headers={"Authorization": f"Bearer {token2}"})
assert res1.status_code == 200
assert res2.status_code == 200
# Each business sees only their own empty list
assert res1.json() == []
assert res2.json() == []
# ---------------------------------------------------------------------------
# Login
# ---------------------------------------------------------------------------
async def test_login_success(client: AsyncClient, db):
business = await make_business(db, name="Auth Test")
await make_user(db, business.id, email="test@example.com", password="mypassword")
response = await client.post("/auth/login", json={
"email": "test@example.com",
"password": "mypassword",
})
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
async def test_login_wrong_password(client: AsyncClient, db):
business = await make_business(db, name="Wrong Pass Test")
await make_user(db, business.id, email="wrong@example.com", password="correct")
response = await client.post("/auth/login", json={
"email": "wrong@example.com",
"password": "incorrect",
})
assert response.status_code == 401
async def test_login_unknown_email(client: AsyncClient, db):
response = await client.post("/auth/login", json={
"email": "noexiste@example.com",
"password": "cualquiera",
})
assert response.status_code == 401
async def test_protected_endpoint_without_token(client: AsyncClient):
response = await client.get("/business/me")
assert response.status_code == 403
async def test_protected_endpoint_with_token(client: AsyncClient, db):
business = await make_business(db, name="Protected Test")
await make_user(db, business.id, email="protected@example.com", password="pass123")
login = await client.post("/auth/login", json={
"email": "protected@example.com",
"password": "pass123",
})
token = login.json()["access_token"]
response = await client.get("/business/me", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 200
data = response.json()
assert data["id"] == business.id
assert data["name"] == "Protected Test"
async def test_logout_returns_204(client: AsyncClient):
response = await client.post("/auth/logout")
assert response.status_code == 204
async def test_refresh_token(client: AsyncClient, db):
business = await make_business(db, name="Refresh Test")
await make_user(db, business.id, email="refresh@example.com", password="pass")
login = await client.post("/auth/login", json={
"email": "refresh@example.com",
"password": "pass",
})
token = login.json()["access_token"]
response = await client.post("/auth/refresh", json={"access_token": token})
assert response.status_code == 200
assert "access_token" in response.json()

View File

@ -0,0 +1,140 @@
from datetime import date, time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.modules.bot_engine.prompt import build_system_prompt
from app.modules.calendar.service import _generate_slots
from app.modules.bot_engine.schemas import CollectedData, ConversationContext
from app.modules.calendar.schemas import DayAvailability, SlotRead
def _make_config(**kwargs):
config = MagicMock()
config.tone = kwargs.get("tone", "formal")
config.assistant_name = kwargs.get("assistant_name", "Hermes")
config.open_days = kwargs.get("open_days", [0, 1, 2, 3, 4])
config.open_time = kwargs.get("open_time", time(9, 0))
config.close_time = kwargs.get("close_time", time(18, 0))
config.slot_duration = kwargs.get("slot_duration", 60)
config.max_per_slot = kwargs.get("max_per_slot", 2)
return config
def _make_business(**kwargs):
b = MagicMock()
b.name = kwargs.get("name", "Restaurante Demo")
b.id = 1
b.whatsapp_phone_number_id = "phone-id"
b.whatsapp_access_token = "token"
return b
def test_build_system_prompt_contains_business_name():
business = _make_business(name="Clínica Sol")
config = _make_config(assistant_name="Sol")
context = ConversationContext(phone="5491100000000", business_id=1)
prompt = build_system_prompt(business, config, None, context)
assert "Clínica Sol" in prompt
assert "Sol" in prompt
def test_build_system_prompt_formal_tone():
business = _make_business()
config = _make_config(tone="formal")
context = ConversationContext(phone="549", business_id=1)
prompt = build_system_prompt(business, config, None, context)
assert "formal" in prompt.lower()
def test_build_system_prompt_casual_tone():
business = _make_business()
config = _make_config(tone="casual")
context = ConversationContext(phone="549", business_id=1)
prompt = build_system_prompt(business, config, None, context)
assert "amigable" in prompt.lower()
def test_build_system_prompt_with_slots():
business = _make_business()
config = _make_config()
context = ConversationContext(phone="549", business_id=1)
availability = DayAvailability(
date=date(2026, 5, 5),
is_open=True,
slots=[
SlotRead(time_start=time(10, 0), time_end=time(11, 0), available=2, max_per_slot=2),
SlotRead(time_start=time(11, 0), time_end=time(12, 0), available=1, max_per_slot=2),
],
)
prompt = build_system_prompt(business, config, availability, context)
assert "10:00" in prompt
assert "11:00" in prompt
def test_build_system_prompt_no_availability():
business = _make_business()
config = _make_config()
context = ConversationContext(phone="549", business_id=1)
availability = DayAvailability(date=date(2026, 5, 5), is_open=False, slots=[])
prompt = build_system_prompt(business, config, availability, context)
assert "No hay disponibilidad" in prompt
def test_build_system_prompt_collected_data_shown():
business = _make_business()
config = _make_config()
context = ConversationContext(
phone="549",
business_id=1,
collected_data=CollectedData(
client_name="Juan",
date="2026-05-05",
time_start="10:00",
party_size=3,
),
)
prompt = build_system_prompt(business, config, None, context)
assert "Juan" in prompt
assert "2026-05-05" in prompt
assert "10:00" in prompt
assert "3" in prompt
def test_build_system_prompt_requires_json_response():
business = _make_business()
config = _make_config()
context = ConversationContext(phone="549", business_id=1)
prompt = build_system_prompt(business, config, None, context)
assert "create_reservation" in prompt
assert "collect_more" in prompt
assert "cancel" in prompt
@pytest.mark.skip(reason="requiere asyncpg — test de integración")
async def test_process_message_calls_claude_and_sends_reply(db, redis_mock):
from app.modules.bot_engine import service as bot_service
from app.modules.bot_engine.schemas import BotResponse, CollectedData
business = _make_business()
bot_response = BotResponse(
message="Hola, ¿cuál es tu nombre?",
action="collect_more",
collected_data=CollectedData(),
)
with (
patch.object(bot_service, "_load_context", return_value=ConversationContext(phone="549", business_id=1)),
patch("app.modules.bot_engine.service.get_business_config", return_value=_make_config()),
patch.object(bot_service, "_call_claude", return_value=bot_response),
patch.object(bot_service, "_save_context"),
patch("app.modules.bot_engine.service.send_text_message", new_callable=AsyncMock) as mock_send,
patch("app.modules.bot_engine.service.aioredis.from_url", return_value=redis_mock),
):
await bot_service.process_message(db=db, phone="549", text="Hola", business=business)
mock_send.assert_called_once_with(
phone_number_id="phone-id",
access_token="token",
to="549",
text="Hola, ¿cuál es tu nombre?",
)

View File

@ -0,0 +1,81 @@
from datetime import date, time
import pytest
from app.modules.calendar.service import _generate_slots, get_available_slots
from tests.conftest import make_business
def test_generate_slots_basic():
slots = _generate_slots(time(9, 0), time(12, 0), 60)
assert len(slots) == 3
assert slots[0] == (time(9, 0), time(10, 0))
assert slots[1] == (time(10, 0), time(11, 0))
assert slots[2] == (time(11, 0), time(12, 0))
def test_generate_slots_30min():
slots = _generate_slots(time(9, 0), time(10, 0), 30)
assert len(slots) == 2
assert slots[0] == (time(9, 0), time(9, 30))
assert slots[1] == (time(9, 30), time(10, 0))
def test_generate_slots_exact_fit():
slots = _generate_slots(time(9, 0), time(9, 30), 30)
assert len(slots) == 1
def test_generate_slots_no_fit():
# slot_duration mayor que el rango → lista vacía
slots = _generate_slots(time(9, 0), time(9, 20), 30)
assert len(slots) == 0
async def test_availability_closed_day(db, redis_mock):
business = await make_business(db, name="Test Cerrado")
# open_days = [0,1,2,3,4] → domingo (6) está cerrado
sunday = date(2026, 4, 26) # domingo
result = await get_available_slots(db, redis_mock, business.id, sunday)
assert result.is_open is False
assert result.slots == []
async def test_availability_open_day_no_reservations(db, redis_mock):
business = await make_business(db, name="Test Abierto")
monday = date(2026, 4, 27) # lunes
result = await get_available_slots(db, redis_mock, business.id, monday)
assert result.is_open is True
assert len(result.slots) == 9 # 09:0018:00 en slots de 60 min
assert all(s.available == 2 for s in result.slots) # max_per_slot=2, sin reservas
async def test_availability_blocked_date(db, redis_mock):
from datetime import date as d
business = await make_business(db, name="Test Bloqueado")
# Bloquear el lunes
monday = date(2026, 4, 27)
from app.modules.business.service import get_business_config
config = await get_business_config(db, business.id)
config.blocked_dates = [monday]
await db.commit()
result = await get_available_slots(db, redis_mock, business.id, monday)
assert result.is_open is False
async def test_availability_cached(db, redis_mock):
import json
from app.modules.calendar.schemas import DayAvailability
business = await make_business(db, name="Test Cache")
monday = date(2026, 4, 27)
cached_data = DayAvailability(date=monday, is_open=False, slots=[])
redis_mock.get = pytest.AsyncMock(return_value=cached_data.model_dump_json())
result = await get_available_slots(db, redis_mock, business.id, monday)
assert result.is_open is False
# Redis.get fue llamado → no consulta DB
redis_mock.get.assert_called_once()

View File

@ -0,0 +1,118 @@
from datetime import date, time
import pytest
from app.modules.reservations.schemas import ReservationCreate, ReservationUpdate, StatusUpdate
from app.modules.reservations.service import (
create_reservation,
delete_reservation,
get_reservation,
list_reservations,
update_status,
)
from tests.conftest import make_business, make_user
async def _create_test_reservation(db, redis_mock, business_id):
return await create_reservation(
db=db,
redis=redis_mock,
business_id=business_id,
data=ReservationCreate(
client_name="Ana García",
client_phone="5491112345678",
date=date(2026, 5, 5),
time_start=time(10, 0),
party_size=2,
source="manual",
),
)
async def test_create_reservation(db, redis_mock):
business = await make_business(db, name="Res Test")
r = await _create_test_reservation(db, redis_mock, business.id)
assert r.id is not None
assert r.client_name == "Ana García"
assert r.time_end == time(11, 0) # 10:00 + 60min slot
assert r.status == "pending"
assert r.source == "manual"
redis_mock.delete.assert_called_once() # caché invalidada
async def test_list_reservations_filter_by_date(db, redis_mock):
business = await make_business(db, name="List Test")
await _create_test_reservation(db, redis_mock, business.id)
results = await list_reservations(db, business.id, filter_date=date(2026, 5, 5))
assert len(results) >= 1
assert all(r.date == date(2026, 5, 5) for r in results)
async def test_list_reservations_filter_by_status(db, redis_mock):
business = await make_business(db, name="Status Test")
await _create_test_reservation(db, redis_mock, business.id)
results = await list_reservations(db, business.id, filter_status="pending")
assert all(r.status == "pending" for r in results)
async def test_get_reservation_not_found(db):
business = await make_business(db, name="404 Test")
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc:
await get_reservation(db, business.id, 999999)
assert exc.value.status_code == 404
async def test_get_reservation_wrong_business(db, redis_mock):
b1 = await make_business(db, name="Business 1")
b2 = await make_business(db, name="Business 2")
r = await _create_test_reservation(db, redis_mock, b1.id)
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc:
await get_reservation(db, b2.id, r.id)
assert exc.value.status_code == 404
async def test_update_status_to_confirmed(db, redis_mock):
business = await make_business(db, name="Confirm Test")
r = await _create_test_reservation(db, redis_mock, business.id)
updated = await update_status(db, redis_mock, business.id, r.id, "confirmed")
assert updated.status == "confirmed"
async def test_update_status_cancelled_invalidates_cache(db, redis_mock):
business = await make_business(db, name="Cancel Cache Test")
r = await _create_test_reservation(db, redis_mock, business.id)
redis_mock.delete.reset_mock()
await update_status(db, redis_mock, business.id, r.id, "cancelled")
redis_mock.delete.assert_called_once() # caché invalidada al cancelar
async def test_update_status_invalid_raises(db, redis_mock):
business = await make_business(db, name="Invalid Status Test")
r = await _create_test_reservation(db, redis_mock, business.id)
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc:
await update_status(db, redis_mock, business.id, r.id, "inexistente")
assert exc.value.status_code == 422
async def test_delete_reservation(db, redis_mock):
business = await make_business(db, name="Delete Test")
r = await _create_test_reservation(db, redis_mock, business.id)
r_id = r.id
redis_mock.delete.reset_mock()
await delete_reservation(db, redis_mock, business.id, r_id)
redis_mock.delete.assert_called_once()
from fastapi import HTTPException
with pytest.raises(HTTPException):
await get_reservation(db, business.id, r_id)

View File

@ -0,0 +1,42 @@
import pytest
from jose import JWTError
from app.core.security import (
create_access_token,
decode_token,
hash_password,
verify_password,
)
def test_hash_and_verify_password():
plain = "supersecret123"
hashed = hash_password(plain)
assert hashed != plain
assert verify_password(plain, hashed)
def test_wrong_password_fails():
hashed = hash_password("correct")
assert not verify_password("wrong", hashed)
def test_create_and_decode_token():
data = {"sub": "42", "business_id": 7}
token = create_access_token(data)
payload = decode_token(token)
assert payload["sub"] == "42"
assert payload["business_id"] == 7
def test_tampered_token_raises():
token = create_access_token({"sub": "1"})
tampered = token[:-5] + "XXXXX"
with pytest.raises(JWTError):
decode_token(tampered)
def test_token_contains_expiry():
token = create_access_token({"sub": "1"})
payload = decode_token(token)
assert "exp" in payload

View File

@ -0,0 +1,71 @@
import hashlib
import hmac
import pytest
from fastapi import HTTPException
from app.modules.whatsapp.service import verify_signature
def _make_signature(secret: str, body: bytes) -> str:
digest = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return f"sha256={digest}"
def test_valid_signature_passes(monkeypatch):
monkeypatch.setattr("app.modules.whatsapp.service.settings.META_APP_SECRET", "mysecret")
body = b'{"object":"whatsapp_business_account"}'
sig = _make_signature("mysecret", body)
verify_signature(body, sig) # no debe lanzar
def test_invalid_signature_raises(monkeypatch):
monkeypatch.setattr("app.modules.whatsapp.service.settings.META_APP_SECRET", "mysecret")
body = b'{"object":"whatsapp_business_account"}'
with pytest.raises(HTTPException) as exc:
verify_signature(body, "sha256=invalidsignature")
assert exc.value.status_code == 403
def test_missing_signature_raises(monkeypatch):
monkeypatch.setattr("app.modules.whatsapp.service.settings.META_APP_SECRET", "mysecret")
with pytest.raises(HTTPException) as exc:
verify_signature(b"body", "")
assert exc.value.status_code == 403
def test_missing_prefix_raises(monkeypatch):
monkeypatch.setattr("app.modules.whatsapp.service.settings.META_APP_SECRET", "mysecret")
with pytest.raises(HTTPException):
verify_signature(b"body", "notsha256=abc")
async def test_webhook_verification_endpoint(client_no_db):
import app.core.config as cfg
cfg.settings.META_WEBHOOK_VERIFY_TOKEN = "test-verify-token"
response = await client_no_db.get(
"/whatsapp/webhook",
params={
"hub.mode": "subscribe",
"hub.verify_token": "test-verify-token",
"hub.challenge": "challenge123",
},
)
assert response.status_code == 200
assert response.text == "challenge123"
async def test_webhook_verification_wrong_token(client_no_db):
import app.core.config as cfg
cfg.settings.META_WEBHOOK_VERIFY_TOKEN = "test-verify-token"
response = await client_no_db.get(
"/whatsapp/webhook",
params={
"hub.mode": "subscribe",
"hub.verify_token": "wrong-token",
"hub.challenge": "abc",
},
)
assert response.status_code == 403