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:
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
115
backend/tests/conftest.py
Normal file
115
backend/tests/conftest.py
Normal 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
164
backend/tests/test_auth.py
Normal 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()
|
||||
140
backend/tests/test_bot_engine.py
Normal file
140
backend/tests/test_bot_engine.py
Normal 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?",
|
||||
)
|
||||
81
backend/tests/test_calendar.py
Normal file
81
backend/tests/test_calendar.py
Normal 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:00–18: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()
|
||||
118
backend/tests/test_reservations.py
Normal file
118
backend/tests/test_reservations.py
Normal 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)
|
||||
42
backend/tests/test_security.py
Normal file
42
backend/tests/test_security.py
Normal 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
|
||||
71
backend/tests/test_webhook.py
Normal file
71
backend/tests/test_webhook.py
Normal 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
|
||||
Reference in New Issue
Block a user