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>
165 lines
6.0 KiB
Python
165 lines
6.0 KiB
Python
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()
|