mirror of
http://88.130.71.182:3000/BlitTech/badoHair_be.git
synced 2026-06-12 23:23:22 +00:00
Initial Commit
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
202
tests/conftest.py
Normal file
202
tests/conftest.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Shared fixtures for all tests.
|
||||
|
||||
Strategy:
|
||||
- Override `get_db` with an async generator that yields a mock asyncpg connection.
|
||||
- Override `get_current_user` / `require_admin` directly with plain async functions.
|
||||
- Patch `app.database.get_pool` so the lifespan startup never touches a real DB.
|
||||
- Patch `app.services.stripe_service` globally so no real Stripe calls are made.
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import datetime, date, time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from main import app
|
||||
from app.dependencies import get_db, get_current_user, get_current_user_optional, require_admin
|
||||
|
||||
# ── Sample data ───────────────────────────────────────────────────────────────
|
||||
|
||||
USER_ID = "11111111-1111-1111-1111-111111111111"
|
||||
ADMIN_ID = "22222222-2222-2222-2222-222222222222"
|
||||
PRODUCT_ID = "33333333-3333-3333-3333-333333333333"
|
||||
SLOT_ID = "44444444-4444-4444-4444-444444444444"
|
||||
BOOKING_ID = "55555555-5555-5555-5555-555555555555"
|
||||
ORDER_ID = "66666666-6666-6666-6666-666666666666"
|
||||
|
||||
SAMPLE_USER = {
|
||||
"id": USER_ID,
|
||||
"email": "user@test.com",
|
||||
"full_name": "Test User",
|
||||
"phone": "+49123456789",
|
||||
"role": "client",
|
||||
"is_blocked": False,
|
||||
}
|
||||
|
||||
SAMPLE_ADMIN = {
|
||||
"id": ADMIN_ID,
|
||||
"email": "admin@test.com",
|
||||
"full_name": "Admin User",
|
||||
"phone": None,
|
||||
"role": "admin",
|
||||
"is_blocked": False,
|
||||
}
|
||||
|
||||
SAMPLE_PRODUCT = {
|
||||
"id": UUID(PRODUCT_ID),
|
||||
"name": "Extensions Clip-In Luxe",
|
||||
"description": "Top quality",
|
||||
"price": 189.0,
|
||||
"original_price": 229.0,
|
||||
"category": "clip-in",
|
||||
"images": ["https://example.com/img.jpg"],
|
||||
"_raw_images": ["https://example.com/img.jpg"],
|
||||
"image": "https://example.com/img.jpg",
|
||||
"colors": ["Black", "Brown"],
|
||||
"lengths": ["40cm", "50cm"],
|
||||
"features": ["100% Remy"],
|
||||
"stock_quantity": 10,
|
||||
"is_featured": False,
|
||||
"is_hidden": False,
|
||||
"is_new": True,
|
||||
"is_bestseller": True,
|
||||
"rating": 4.8,
|
||||
"review_count": 124,
|
||||
"created_at": datetime(2026, 1, 1),
|
||||
"updated_at": datetime(2026, 1, 1),
|
||||
}
|
||||
|
||||
SAMPLE_SLOT = {
|
||||
"id": UUID(SLOT_ID),
|
||||
"date": date(2026, 6, 1),
|
||||
"start_time": time(10, 0),
|
||||
"end_time": time(11, 0),
|
||||
"is_blocked": False,
|
||||
"block_reason": None,
|
||||
"is_booked": False,
|
||||
}
|
||||
|
||||
SAMPLE_BOOKING = {
|
||||
"id": UUID(BOOKING_ID),
|
||||
"user_id": UUID(USER_ID),
|
||||
"slot_id": UUID(SLOT_ID),
|
||||
"slot_date": date(2026, 6, 1),
|
||||
"slot_start": "10:00",
|
||||
"slot_end": "11:00",
|
||||
"service_note": "Box braids",
|
||||
"client_name": "Test User",
|
||||
"client_email": "user@test.com",
|
||||
"client_phone": "+49123456789",
|
||||
"status": "confirmed",
|
||||
"amount_paid": 50.0,
|
||||
"stripe_payment_intent_id": "pi_test123",
|
||||
"admin_notes": None,
|
||||
"created_at": datetime(2026, 1, 1),
|
||||
"updated_at": datetime(2026, 1, 1),
|
||||
}
|
||||
|
||||
SAMPLE_SERVICE = {
|
||||
"id": UUID("77777777-7777-7777-7777-777777777777"),
|
||||
"name": "Pose complète",
|
||||
"description": "Full extension installation",
|
||||
"duration_minutes": 150,
|
||||
"price": 150.0,
|
||||
}
|
||||
|
||||
|
||||
# ── Mock asyncpg transaction context manager ──────────────────────────────────
|
||||
|
||||
class MockTransaction:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
async def __aexit__(self, *args):
|
||||
return False
|
||||
|
||||
|
||||
# ── DB mock factory ───────────────────────────────────────────────────────────
|
||||
|
||||
def make_mock_db() -> AsyncMock:
|
||||
db = AsyncMock()
|
||||
db.fetchrow = AsyncMock(return_value=None)
|
||||
db.fetch = AsyncMock(return_value=[])
|
||||
db.fetchval = AsyncMock(return_value=0)
|
||||
db.execute = AsyncMock(return_value="INSERT 1")
|
||||
db.transaction = MagicMock(return_value=MockTransaction())
|
||||
return db
|
||||
|
||||
|
||||
def db_override(mock_conn):
|
||||
async def _override():
|
||||
yield mock_conn
|
||||
return _override
|
||||
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
return make_mock_db()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_pool():
|
||||
"""Prevent lifespan from opening a real DB pool."""
|
||||
with patch("app.database.get_pool", new_callable=AsyncMock) as m:
|
||||
m.return_value = AsyncMock()
|
||||
yield m
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_stripe():
|
||||
"""Prevent any real Stripe calls."""
|
||||
intent = MagicMock()
|
||||
intent.id = "pi_test123"
|
||||
intent.client_secret = "pi_test123_secret"
|
||||
with patch("app.services.stripe_service.create_payment_intent", new_callable=AsyncMock, return_value=intent):
|
||||
with patch("app.services.stripe_service.create_refund", new_callable=AsyncMock):
|
||||
with patch("app.services.stripe_service.verify_webhook") as mock_wh:
|
||||
mock_wh.return_value = MagicMock(
|
||||
type="payment_intent.succeeded",
|
||||
get=MagicMock(return_value={
|
||||
"object": {"id": "pi_test123", "metadata": {"entity_type": "order", "entity_id": ORDER_ID}}
|
||||
}),
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_stripe_modify():
|
||||
"""Patch the inline stripe.PaymentIntent.modify call in booking_service."""
|
||||
with patch("stripe.PaymentIntent.modify", return_value=MagicMock()):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def anon_client(mock_db):
|
||||
app.dependency_overrides[get_db] = db_override(mock_db)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_client(mock_db):
|
||||
app.dependency_overrides[get_db] = db_override(mock_db)
|
||||
app.dependency_overrides[get_current_user] = lambda: SAMPLE_USER
|
||||
app.dependency_overrides[get_current_user_optional] = lambda: SAMPLE_USER
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_client(mock_db):
|
||||
app.dependency_overrides[get_db] = db_override(mock_db)
|
||||
app.dependency_overrides[get_current_user] = lambda: SAMPLE_ADMIN
|
||||
app.dependency_overrides[require_admin] = lambda: SAMPLE_ADMIN
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c
|
||||
app.dependency_overrides.clear()
|
||||
57
tests/test_admin_dashboard.py
Normal file
57
tests/test_admin_dashboard.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Admin dashboard stats — verify all fields the frontend needs are present."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
|
||||
async def test_overview_returns_all_frontend_fields(admin_client, mock_db):
|
||||
mock_db.fetchval = AsyncMock(return_value=0)
|
||||
|
||||
r = await admin_client.get("/api/v1/admin/stats/overview")
|
||||
assert r.status_code == 200
|
||||
data = r.json()["data"]
|
||||
|
||||
# Revenue (extra, for future admin analytics)
|
||||
assert "revenue_today" in data
|
||||
assert "revenue_week" in data
|
||||
assert "revenue_month" in data
|
||||
|
||||
# Fields the frontend dashboard currently displays
|
||||
assert "products_count" in data
|
||||
assert "bookings_pending" in data
|
||||
assert "bookings_confirmed" in data
|
||||
assert "catalog_value" in data
|
||||
|
||||
# Bonus fields
|
||||
assert "low_stock_count" in data
|
||||
assert "new_customers_month" in data
|
||||
|
||||
|
||||
async def test_overview_requires_admin(auth_client, mock_db):
|
||||
mock_db.fetchval = AsyncMock(return_value=0)
|
||||
r = await auth_client.get("/api/v1/admin/stats/overview")
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
async def test_overview_requires_auth(anon_client):
|
||||
r = await anon_client.get("/api/v1/admin/stats/overview")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
async def test_revenue_stats(admin_client, mock_db):
|
||||
mock_db.fetch = AsyncMock(return_value=[])
|
||||
r = await admin_client.get("/api/v1/admin/stats/revenue?period=month")
|
||||
assert r.status_code == 200
|
||||
data = r.json()["data"]
|
||||
assert "orders" in data
|
||||
assert "bookings" in data
|
||||
|
||||
|
||||
async def test_revenue_invalid_period(admin_client):
|
||||
r = await admin_client.get("/api/v1/admin/stats/revenue?period=decade")
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
async def test_activity_feed(admin_client, mock_db):
|
||||
mock_db.fetch = AsyncMock(return_value=[])
|
||||
r = await admin_client.get("/api/v1/admin/stats/activity")
|
||||
assert r.status_code == 200
|
||||
assert isinstance(r.json()["data"], list)
|
||||
113
tests/test_auth.py
Normal file
113
tests/test_auth.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Auth endpoint tests."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from tests.conftest import SAMPLE_USER
|
||||
|
||||
|
||||
def _supabase_session(access_token="tok_access", refresh_token="tok_refresh"):
|
||||
session = MagicMock()
|
||||
session.access_token = access_token
|
||||
session.refresh_token = refresh_token
|
||||
session.expires_in = 3600
|
||||
user = MagicMock()
|
||||
user.id = "11111111-1111-1111-1111-111111111111"
|
||||
result = MagicMock()
|
||||
result.session = session
|
||||
result.user = user
|
||||
return result
|
||||
|
||||
|
||||
async def test_login_success(anon_client):
|
||||
with patch("app.services.auth_service._client") as mock_client:
|
||||
mock_client.return_value.auth.sign_in_with_password.return_value = _supabase_session()
|
||||
r = await anon_client.post("/api/v1/auth/login", json={"email": "user@test.com", "password": "password123"})
|
||||
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["success"] is True
|
||||
assert "access_token" in body["data"]
|
||||
assert body["data"]["token_type"] == "bearer"
|
||||
|
||||
|
||||
async def test_login_invalid_credentials(anon_client):
|
||||
with patch("app.services.auth_service._client") as mock_client:
|
||||
mock_client.return_value.auth.sign_in_with_password.side_effect = Exception("Invalid login")
|
||||
r = await anon_client.post("/api/v1/auth/login", json={"email": "wrong@test.com", "password": "bad"})
|
||||
|
||||
assert r.status_code == 401
|
||||
body = r.json()
|
||||
assert body["success"] is False
|
||||
assert body["error"]["code"] == "UNAUTHORIZED"
|
||||
|
||||
|
||||
async def test_register_with_name_field(anon_client, mock_db):
|
||||
"""Frontend sends `name`, not `full_name` — both must be accepted."""
|
||||
mock_db.execute = AsyncMock(return_value="INSERT 1")
|
||||
|
||||
with patch("app.services.auth_service._client") as mock_client:
|
||||
mock_client.return_value.auth.sign_up.return_value = _supabase_session()
|
||||
r = await anon_client.post("/api/v1/auth/register", json={
|
||||
"email": "new@test.com",
|
||||
"password": "password123",
|
||||
"name": "Jane Doe",
|
||||
})
|
||||
|
||||
assert r.status_code == 201
|
||||
assert r.json()["success"] is True
|
||||
|
||||
|
||||
async def test_register_with_full_name_field(anon_client, mock_db):
|
||||
"""full_name field also accepted."""
|
||||
mock_db.execute = AsyncMock(return_value="INSERT 1")
|
||||
|
||||
with patch("app.services.auth_service._client") as mock_client:
|
||||
mock_client.return_value.auth.sign_up.return_value = _supabase_session()
|
||||
r = await anon_client.post("/api/v1/auth/register", json={
|
||||
"email": "new2@test.com",
|
||||
"password": "password123",
|
||||
"full_name": "Jane Doe",
|
||||
})
|
||||
|
||||
assert r.status_code == 201
|
||||
|
||||
|
||||
async def test_register_password_too_short(anon_client):
|
||||
r = await anon_client.post("/api/v1/auth/register", json={
|
||||
"email": "new@test.com",
|
||||
"password": "short",
|
||||
"name": "Jane",
|
||||
})
|
||||
assert r.status_code == 422
|
||||
body = r.json()
|
||||
assert body["success"] is False
|
||||
assert body["error"]["code"] == "VALIDATION_ERROR"
|
||||
|
||||
|
||||
async def test_get_me(auth_client):
|
||||
r = await auth_client.get("/api/v1/auth/me")
|
||||
assert r.status_code == 200
|
||||
data = r.json()["data"]
|
||||
assert data["email"] == SAMPLE_USER["email"]
|
||||
assert data["role"] == "client"
|
||||
|
||||
|
||||
async def test_get_me_unauthenticated(anon_client):
|
||||
r = await anon_client.get("/api/v1/auth/me")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
async def test_forgot_password_always_200(anon_client):
|
||||
"""Never reveals whether email exists."""
|
||||
with patch("app.services.auth_service._client"):
|
||||
r = await anon_client.post("/api/v1/auth/forgot-password", json={"email": "anyone@test.com"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["success"] is True
|
||||
|
||||
|
||||
async def test_update_profile(auth_client, mock_db):
|
||||
from tests.conftest import SAMPLE_USER
|
||||
updated = dict(SAMPLE_USER, full_name="Updated Name")
|
||||
mock_db.fetchrow = AsyncMock(return_value=updated)
|
||||
|
||||
r = await auth_client.patch("/api/v1/auth/me", json={"full_name": "Updated Name"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["data"]["full_name"] == "Updated Name"
|
||||
190
tests/test_bookings.py
Normal file
190
tests/test_bookings.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Booking endpoint tests — guest booking, auth booking, slots, admin actions."""
|
||||
from datetime import date, time
|
||||
from unittest.mock import AsyncMock
|
||||
from tests.conftest import SAMPLE_BOOKING, SAMPLE_SLOT, SLOT_ID, BOOKING_ID, USER_ID
|
||||
|
||||
|
||||
# ── Available slots ───────────────────────────────────────────────────────────
|
||||
|
||||
async def test_get_available_slots(anon_client, mock_db):
|
||||
mock_db.fetch = AsyncMock(return_value=[SAMPLE_SLOT])
|
||||
|
||||
r = await anon_client.get("/api/v1/bookings/slots?from_date=2026-06-01&to_date=2026-06-07")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["success"] is True
|
||||
assert isinstance(body["data"], list)
|
||||
|
||||
|
||||
async def test_get_slots_missing_date_param(anon_client):
|
||||
r = await anon_client.get("/api/v1/bookings/slots?from_date=2026-06-01")
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
# ── Guest booking ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_guest_booking_success(anon_client, mock_db):
|
||||
mock_db.fetchrow = AsyncMock(side_effect=[
|
||||
SAMPLE_SLOT, # slot availability check
|
||||
SAMPLE_BOOKING, # INSERT booking
|
||||
])
|
||||
mock_db.fetchval = AsyncMock(return_value=50.0) # default booking price
|
||||
|
||||
r = await anon_client.post("/api/v1/bookings", json={
|
||||
"slot_id": SLOT_ID,
|
||||
"service_note": "Box braids",
|
||||
"guest_name": "Marie Dupont",
|
||||
"guest_email": "marie@test.com",
|
||||
"guest_phone": "+49123456789",
|
||||
})
|
||||
assert r.status_code == 201
|
||||
body = r.json()
|
||||
assert body["success"] is True
|
||||
assert "client_secret" in body["data"]
|
||||
assert "booking_id" in body["data"]
|
||||
|
||||
|
||||
async def test_guest_booking_missing_email_rejected(anon_client, mock_db):
|
||||
"""Unauthenticated booking without guest_email must fail with 422."""
|
||||
r = await anon_client.post("/api/v1/bookings", json={
|
||||
"slot_id": SLOT_ID,
|
||||
"guest_name": "Marie",
|
||||
# guest_email missing
|
||||
})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
async def test_guest_booking_slot_unavailable(anon_client, mock_db):
|
||||
mock_db.fetchrow = AsyncMock(return_value=None) # slot check returns nothing → unavailable
|
||||
|
||||
r = await anon_client.post("/api/v1/bookings", json={
|
||||
"slot_id": SLOT_ID,
|
||||
"guest_name": "Marie",
|
||||
"guest_email": "marie@test.com",
|
||||
})
|
||||
assert r.status_code == 409
|
||||
assert r.json()["error"]["code"] == "SLOT_UNAVAILABLE"
|
||||
|
||||
|
||||
# ── Authenticated booking ─────────────────────────────────────────────────────
|
||||
|
||||
async def test_auth_booking_success(auth_client, mock_db):
|
||||
mock_db.fetchrow = AsyncMock(side_effect=[
|
||||
SAMPLE_SLOT,
|
||||
SAMPLE_BOOKING,
|
||||
])
|
||||
mock_db.fetchval = AsyncMock(return_value=50.0)
|
||||
|
||||
r = await auth_client.post("/api/v1/bookings", json={
|
||||
"slot_id": SLOT_ID,
|
||||
"service_note": "Tape-in maintenance",
|
||||
})
|
||||
assert r.status_code == 201
|
||||
assert r.json()["success"] is True
|
||||
|
||||
|
||||
# ── List / get my bookings ────────────────────────────────────────────────────
|
||||
|
||||
async def test_list_my_bookings(auth_client, mock_db):
|
||||
mock_db.fetchval = AsyncMock(return_value=1)
|
||||
mock_db.fetch = AsyncMock(return_value=[SAMPLE_BOOKING])
|
||||
|
||||
r = await auth_client.get("/api/v1/bookings")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["meta"]["total"] == 1
|
||||
assert body["data"][0]["status"] == "confirmed"
|
||||
|
||||
|
||||
async def test_get_booking_by_id(auth_client, mock_db):
|
||||
mock_db.fetchrow = AsyncMock(return_value=SAMPLE_BOOKING)
|
||||
|
||||
r = await auth_client.get(f"/api/v1/bookings/{BOOKING_ID}")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["data"]["id"] == BOOKING_ID
|
||||
|
||||
|
||||
async def test_get_booking_not_found(auth_client, mock_db):
|
||||
mock_db.fetchrow = AsyncMock(return_value=None)
|
||||
r = await auth_client.get(f"/api/v1/bookings/00000000-0000-0000-0000-000000000000")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
async def test_list_bookings_requires_auth(anon_client):
|
||||
r = await anon_client.get("/api/v1/bookings")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# ── Cancel booking ────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_cancel_booking_success(auth_client, mock_db):
|
||||
pending_booking = {**SAMPLE_BOOKING, "status": "pending", "user_id": USER_ID}
|
||||
mock_db.fetchrow = AsyncMock(return_value=pending_booking)
|
||||
mock_db.execute = AsyncMock(return_value="UPDATE 1")
|
||||
|
||||
r = await auth_client.delete(f"/api/v1/bookings/{BOOKING_ID}")
|
||||
assert r.status_code == 204
|
||||
|
||||
|
||||
async def test_cancel_already_cancelled_booking(auth_client, mock_db):
|
||||
cancelled = {**SAMPLE_BOOKING, "status": "cancelled", "user_id": USER_ID}
|
||||
mock_db.fetchrow = AsyncMock(return_value=cancelled)
|
||||
|
||||
r = await auth_client.delete(f"/api/v1/bookings/{BOOKING_ID}")
|
||||
assert r.status_code == 400
|
||||
assert r.json()["error"]["code"] == "CANNOT_CANCEL"
|
||||
|
||||
|
||||
async def test_cancel_booking_not_found(auth_client, mock_db):
|
||||
mock_db.fetchrow = AsyncMock(return_value=None)
|
||||
r = await auth_client.delete(f"/api/v1/bookings/00000000-0000-0000-0000-000000000000")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# ── Admin booking management ──────────────────────────────────────────────────
|
||||
|
||||
async def test_admin_list_bookings(admin_client, mock_db):
|
||||
mock_db.fetchval = AsyncMock(return_value=1)
|
||||
mock_db.fetch = AsyncMock(return_value=[SAMPLE_BOOKING])
|
||||
|
||||
r = await admin_client.get("/api/v1/admin/bookings")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["meta"]["total"] == 1
|
||||
# Verify admin response includes resolved client info
|
||||
record = body["data"][0]
|
||||
assert "client_name" in record
|
||||
assert "client_email" in record
|
||||
assert "slot_date" in record
|
||||
assert "slot_start" in record
|
||||
|
||||
|
||||
async def test_admin_confirm_booking(admin_client, mock_db):
|
||||
mock_db.fetchrow = AsyncMock(side_effect=[
|
||||
SAMPLE_BOOKING, # UPDATE bookings RETURNING *
|
||||
None, # SELECT email FROM profiles (skip email sending)
|
||||
SAMPLE_BOOKING, # get_booking JOIN query at the end
|
||||
])
|
||||
mock_db.execute = AsyncMock(return_value="INSERT 1") # activity_log
|
||||
|
||||
r = await admin_client.patch(f"/api/v1/admin/bookings/{BOOKING_ID}", json={"status": "confirmed"})
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
async def test_admin_delete_booking(admin_client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value="DELETE 1")
|
||||
r = await admin_client.delete(f"/api/v1/admin/bookings/{BOOKING_ID}")
|
||||
assert r.status_code == 204
|
||||
|
||||
|
||||
async def test_admin_delete_booking_not_found(admin_client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value="DELETE 0")
|
||||
r = await admin_client.delete(f"/api/v1/admin/bookings/00000000-0000-0000-0000-000000000000")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
async def test_admin_bookings_list_filter_by_status(admin_client, mock_db):
|
||||
mock_db.fetchval = AsyncMock(return_value=0)
|
||||
mock_db.fetch = AsyncMock(return_value=[])
|
||||
r = await admin_client.get("/api/v1/admin/bookings?status=pending")
|
||||
assert r.status_code == 200
|
||||
59
tests/test_error_handling.py
Normal file
59
tests/test_error_handling.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Verify the error envelope is consistent across all error types."""
|
||||
|
||||
|
||||
async def test_404_uses_error_envelope(anon_client, mock_db):
|
||||
from unittest.mock import AsyncMock
|
||||
mock_db.fetchrow = AsyncMock(return_value=None)
|
||||
|
||||
r = await anon_client.get("/api/v1/products/00000000-0000-0000-0000-000000000000")
|
||||
assert r.status_code == 404
|
||||
body = r.json()
|
||||
assert body["success"] is False
|
||||
assert "code" in body["error"]
|
||||
assert "message" in body["error"]
|
||||
assert "data" not in body or body.get("data") is None
|
||||
|
||||
|
||||
async def test_422_uses_error_envelope(auth_client):
|
||||
r = await auth_client.post("/api/v1/orders", json={"items": []})
|
||||
assert r.status_code == 422
|
||||
body = r.json()
|
||||
assert body["success"] is False
|
||||
assert body["error"]["code"] == "VALIDATION_ERROR"
|
||||
assert isinstance(body["error"]["details"], list)
|
||||
|
||||
|
||||
async def test_401_uses_error_envelope(anon_client):
|
||||
from unittest.mock import patch
|
||||
from app.exceptions import UnauthorizedError
|
||||
# Provide a bad token
|
||||
r = await anon_client.get("/api/v1/auth/me", headers={"Authorization": "Bearer invalid.token.here"})
|
||||
assert r.status_code == 401
|
||||
body = r.json()
|
||||
assert body["success"] is False
|
||||
assert body["error"]["code"] == "UNAUTHORIZED"
|
||||
|
||||
|
||||
async def test_403_uses_error_envelope(auth_client):
|
||||
r = await auth_client.get("/api/v1/admin/products")
|
||||
assert r.status_code == 403
|
||||
body = r.json()
|
||||
assert body["success"] is False
|
||||
assert body["error"]["code"] == "FORBIDDEN"
|
||||
|
||||
|
||||
async def test_409_out_of_stock_uses_error_envelope(auth_client, mock_db):
|
||||
from unittest.mock import AsyncMock
|
||||
mock_db.fetchrow = AsyncMock(return_value={
|
||||
"id": "33333333-3333-3333-3333-333333333333",
|
||||
"name": "Test Product",
|
||||
"price": 100.0,
|
||||
"stock_quantity": 0,
|
||||
})
|
||||
r = await auth_client.post("/api/v1/orders", json={
|
||||
"items": [{"product_id": "33333333-3333-3333-3333-333333333333", "quantity": 1}]
|
||||
})
|
||||
assert r.status_code == 409
|
||||
body = r.json()
|
||||
assert body["success"] is False
|
||||
assert body["error"]["code"] == "OUT_OF_STOCK"
|
||||
4
tests/test_health.py
Normal file
4
tests/test_health.py
Normal file
@@ -0,0 +1,4 @@
|
||||
async def test_health(anon_client):
|
||||
r = await anon_client.get("/health")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "ok"
|
||||
123
tests/test_orders.py
Normal file
123
tests/test_orders.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Order creation, stock deduction, and webhook handling."""
|
||||
from unittest.mock import AsyncMock
|
||||
from tests.conftest import PRODUCT_ID, ORDER_ID
|
||||
|
||||
|
||||
SAMPLE_PRODUCT_ROW = {
|
||||
"id": PRODUCT_ID,
|
||||
"name": "Extensions Clip-In Luxe",
|
||||
"price": 189.0,
|
||||
"stock_quantity": 10,
|
||||
}
|
||||
|
||||
SAMPLE_ORDER_ROW = {
|
||||
"id": ORDER_ID,
|
||||
"user_id": "11111111-1111-1111-1111-111111111111",
|
||||
"status": "pending",
|
||||
"total_amount": 189.0,
|
||||
"stripe_payment_intent_id": "pi_test123",
|
||||
"shipping_address": None,
|
||||
"notes": None,
|
||||
"created_at": "2026-01-01T00:00:00",
|
||||
"updated_at": "2026-01-01T00:00:00",
|
||||
}
|
||||
|
||||
|
||||
async def test_create_order_success(auth_client, mock_db):
|
||||
mock_db.fetchrow = AsyncMock(side_effect=[
|
||||
SAMPLE_PRODUCT_ROW, # product lookup
|
||||
{"id": ORDER_ID, **SAMPLE_ORDER_ROW}, # INSERT order
|
||||
])
|
||||
mock_db.execute = AsyncMock(return_value="UPDATE 1")
|
||||
|
||||
r = await auth_client.post("/api/v1/orders", json={
|
||||
"items": [{"product_id": PRODUCT_ID, "quantity": 1}],
|
||||
})
|
||||
assert r.status_code == 201
|
||||
body = r.json()
|
||||
assert body["success"] is True
|
||||
assert "client_secret" in body["data"]
|
||||
assert "order_id" in body["data"]
|
||||
assert body["data"]["amount"] == 189.0
|
||||
|
||||
|
||||
async def test_create_order_requires_auth(anon_client):
|
||||
r = await anon_client.post("/api/v1/orders", json={
|
||||
"items": [{"product_id": PRODUCT_ID, "quantity": 1}],
|
||||
})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
async def test_create_order_empty_items_rejected(auth_client):
|
||||
r = await auth_client.post("/api/v1/orders", json={"items": []})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
async def test_create_order_product_not_found(auth_client, mock_db):
|
||||
mock_db.fetchrow = AsyncMock(return_value=None)
|
||||
|
||||
r = await auth_client.post("/api/v1/orders", json={
|
||||
"items": [{"product_id": "00000000-0000-0000-0000-000000000000", "quantity": 1}],
|
||||
})
|
||||
assert r.status_code == 404
|
||||
assert r.json()["error"]["code"] == "PRODUCT_NOT_FOUND"
|
||||
|
||||
|
||||
async def test_create_order_out_of_stock(auth_client, mock_db):
|
||||
low_stock = {**SAMPLE_PRODUCT_ROW, "stock_quantity": 0}
|
||||
mock_db.fetchrow = AsyncMock(return_value=low_stock)
|
||||
|
||||
r = await auth_client.post("/api/v1/orders", json={
|
||||
"items": [{"product_id": PRODUCT_ID, "quantity": 1}],
|
||||
})
|
||||
assert r.status_code == 409
|
||||
assert r.json()["error"]["code"] == "OUT_OF_STOCK"
|
||||
|
||||
|
||||
async def test_list_my_orders(auth_client, mock_db):
|
||||
mock_db.fetchval = AsyncMock(return_value=1)
|
||||
mock_db.fetch = AsyncMock(return_value=[SAMPLE_ORDER_ROW])
|
||||
|
||||
r = await auth_client.get("/api/v1/orders")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["meta"]["total"] == 1
|
||||
|
||||
|
||||
async def test_get_order_by_id(auth_client, mock_db):
|
||||
mock_db.fetchrow = AsyncMock(return_value=SAMPLE_ORDER_ROW)
|
||||
mock_db.fetch = AsyncMock(return_value=[]) # order items
|
||||
|
||||
r = await auth_client.get(f"/api/v1/orders/{ORDER_ID}")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
async def test_get_order_not_found(auth_client, mock_db):
|
||||
mock_db.fetchrow = AsyncMock(return_value=None)
|
||||
r = await auth_client.get(f"/api/v1/orders/00000000-0000-0000-0000-000000000000")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# ── Admin order management ────────────────────────────────────────────────────
|
||||
|
||||
async def test_admin_list_orders(admin_client, mock_db):
|
||||
mock_db.fetchval = AsyncMock(return_value=1)
|
||||
mock_db.fetch = AsyncMock(return_value=[SAMPLE_ORDER_ROW])
|
||||
|
||||
r = await admin_client.get("/api/v1/admin/orders")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
async def test_admin_update_order_status(admin_client, mock_db):
|
||||
updated = {**SAMPLE_ORDER_ROW, "status": "shipped"}
|
||||
mock_db.fetchrow = AsyncMock(return_value=updated)
|
||||
mock_db.execute = AsyncMock(return_value="INSERT 1")
|
||||
|
||||
r = await admin_client.patch(f"/api/v1/admin/orders/{ORDER_ID}/status", json={"status": "shipped"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["data"]["status"] == "shipped"
|
||||
|
||||
|
||||
async def test_admin_update_order_invalid_status(admin_client):
|
||||
r = await admin_client.patch(f"/api/v1/admin/orders/{ORDER_ID}/status", json={"status": "exploded"})
|
||||
assert r.status_code == 422
|
||||
154
tests/test_products.py
Normal file
154
tests/test_products.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""Product endpoint tests — both public and admin."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from tests.conftest import SAMPLE_PRODUCT, PRODUCT_ID
|
||||
|
||||
|
||||
# ── Public endpoints ──────────────────────────────────────────────────────────
|
||||
|
||||
async def test_list_products_returns_paginated(anon_client, mock_db):
|
||||
mock_db.fetchval = AsyncMock(return_value=1)
|
||||
mock_db.fetch = AsyncMock(return_value=[SAMPLE_PRODUCT])
|
||||
|
||||
r = await anon_client.get("/api/v1/products")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["success"] is True
|
||||
assert isinstance(body["data"], list)
|
||||
assert body["meta"]["total"] == 1
|
||||
assert body["data"][0]["name"] == "Extensions Clip-In Luxe"
|
||||
|
||||
|
||||
async def test_list_products_category_filter(anon_client, mock_db):
|
||||
mock_db.fetchval = AsyncMock(return_value=0)
|
||||
mock_db.fetch = AsyncMock(return_value=[])
|
||||
|
||||
r = await anon_client.get("/api/v1/products?category=tape-in")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["meta"]["total"] == 0
|
||||
|
||||
|
||||
async def test_list_products_bestseller_filter(anon_client, mock_db):
|
||||
mock_db.fetchval = AsyncMock(return_value=1)
|
||||
mock_db.fetch = AsyncMock(return_value=[SAMPLE_PRODUCT])
|
||||
|
||||
r = await anon_client.get("/api/v1/products?bestseller=true")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["data"][0]["is_bestseller"] is True
|
||||
|
||||
|
||||
async def test_list_products_exclude_param(anon_client, mock_db):
|
||||
"""Used to fetch similar products on the product detail page."""
|
||||
mock_db.fetchval = AsyncMock(return_value=0)
|
||||
mock_db.fetch = AsyncMock(return_value=[])
|
||||
|
||||
r = await anon_client.get(f"/api/v1/products?category=clip-in&exclude={PRODUCT_ID}")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
async def test_get_product_found(anon_client, mock_db):
|
||||
mock_db.fetchrow = AsyncMock(return_value=SAMPLE_PRODUCT)
|
||||
|
||||
r = await anon_client.get(f"/api/v1/products/{PRODUCT_ID}")
|
||||
assert r.status_code == 200
|
||||
data = r.json()["data"]
|
||||
assert data["id"] == PRODUCT_ID
|
||||
assert data["category"] == "clip-in"
|
||||
assert isinstance(data["colors"], list)
|
||||
assert isinstance(data["images"], list)
|
||||
assert isinstance(data["image"], str)
|
||||
|
||||
|
||||
async def test_get_product_not_found(anon_client, mock_db):
|
||||
mock_db.fetchrow = AsyncMock(return_value=None)
|
||||
|
||||
r = await anon_client.get(f"/api/v1/products/00000000-0000-0000-0000-000000000000")
|
||||
assert r.status_code == 404
|
||||
body = r.json()
|
||||
assert body["success"] is False
|
||||
assert body["error"]["code"] == "PRODUCT_NOT_FOUND"
|
||||
|
||||
|
||||
async def test_product_response_has_required_frontend_fields(anon_client, mock_db):
|
||||
"""Verify every field the frontend Product interface needs is present."""
|
||||
mock_db.fetchrow = AsyncMock(return_value=SAMPLE_PRODUCT)
|
||||
r = await anon_client.get(f"/api/v1/products/{PRODUCT_ID}")
|
||||
data = r.json()["data"]
|
||||
|
||||
required = ["id", "name", "category", "price", "originalPrice", "image", "images",
|
||||
"colors", "lengths", "description", "features", "isNew", "isBestseller",
|
||||
"rating", "reviewCount"]
|
||||
# Map snake_case API → camelCase frontend expectations
|
||||
api_fields = set(data.keys())
|
||||
# These must exist (snake_case form in our API)
|
||||
for field in ["id", "name", "category", "price", "original_price", "image", "images",
|
||||
"colors", "lengths", "description", "features", "is_new", "is_bestseller",
|
||||
"rating", "review_count"]:
|
||||
assert field in api_fields, f"Missing field: {field}"
|
||||
|
||||
|
||||
# ── Admin product endpoints ───────────────────────────────────────────────────
|
||||
|
||||
async def test_admin_list_products_includes_hidden(admin_client, mock_db):
|
||||
mock_db.fetchval = AsyncMock(return_value=2)
|
||||
mock_db.fetch = AsyncMock(return_value=[SAMPLE_PRODUCT, {**SAMPLE_PRODUCT, "is_hidden": True}])
|
||||
|
||||
r = await admin_client.get("/api/v1/admin/products")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["meta"]["total"] == 2
|
||||
|
||||
|
||||
async def test_admin_create_product(admin_client, mock_db):
|
||||
mock_db.fetchrow = AsyncMock(return_value=SAMPLE_PRODUCT)
|
||||
|
||||
r = await admin_client.post("/api/v1/admin/products", json={
|
||||
"name": "New Extensions",
|
||||
"price": 199.0,
|
||||
"category": "tape-in",
|
||||
"colors": ["Blond", "Brun"],
|
||||
"lengths": ["50cm"],
|
||||
"features": ["100% Remy"],
|
||||
"stock_quantity": 5,
|
||||
})
|
||||
assert r.status_code == 201
|
||||
assert r.json()["success"] is True
|
||||
|
||||
|
||||
async def test_admin_update_product(admin_client, mock_db):
|
||||
mock_db.fetchrow = AsyncMock(return_value=SAMPLE_PRODUCT)
|
||||
|
||||
r = await admin_client.put(f"/api/v1/admin/products/{PRODUCT_ID}", json={"price": 175.0})
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
async def test_admin_delete_product(admin_client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value="DELETE 1")
|
||||
|
||||
r = await admin_client.delete(f"/api/v1/admin/products/{PRODUCT_ID}")
|
||||
assert r.status_code == 204
|
||||
|
||||
|
||||
async def test_admin_delete_product_not_found(admin_client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value="DELETE 0")
|
||||
|
||||
r = await admin_client.delete(f"/api/v1/admin/products/00000000-0000-0000-0000-000000000000")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
async def test_admin_bulk_stock_update(admin_client, mock_db):
|
||||
r = await admin_client.post("/api/v1/admin/products/bulk-stock", json={
|
||||
"updates": [{"id": PRODUCT_ID, "stock_quantity": 20}]
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["data"]["updated"] == 1
|
||||
|
||||
|
||||
async def test_admin_products_requires_admin_role(auth_client):
|
||||
"""A client-role user must get 403."""
|
||||
r = await auth_client.get("/api/v1/admin/products")
|
||||
assert r.status_code == 403
|
||||
assert r.json()["error"]["code"] == "FORBIDDEN"
|
||||
|
||||
|
||||
async def test_admin_products_requires_auth(anon_client):
|
||||
r = await anon_client.get("/api/v1/admin/products")
|
||||
assert r.status_code == 401
|
||||
55
tests/test_services_contact.py
Normal file
55
tests/test_services_contact.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Services list and contact form endpoint tests."""
|
||||
from unittest.mock import AsyncMock
|
||||
from tests.conftest import SAMPLE_SERVICE
|
||||
|
||||
|
||||
async def test_list_services(anon_client, mock_db):
|
||||
mock_db.fetch = AsyncMock(return_value=[SAMPLE_SERVICE])
|
||||
|
||||
r = await anon_client.get("/api/v1/services")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["success"] is True
|
||||
assert isinstance(body["data"], list)
|
||||
assert body["data"][0]["name"] == "Pose complète"
|
||||
assert "duration_minutes" in body["data"][0]
|
||||
assert "price" in body["data"][0]
|
||||
|
||||
|
||||
async def test_list_services_empty(anon_client, mock_db):
|
||||
mock_db.fetch = AsyncMock(return_value=[])
|
||||
r = await anon_client.get("/api/v1/services")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["data"] == []
|
||||
|
||||
|
||||
async def test_contact_form_success(anon_client, mock_db):
|
||||
mock_db.execute = AsyncMock(return_value="INSERT 1")
|
||||
|
||||
r = await anon_client.post("/api/v1/contact", json={
|
||||
"name": "Marie Dupont",
|
||||
"email": "marie@test.com",
|
||||
"message": "Je voudrais prendre rendez-vous.",
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["success"] is True
|
||||
mock_db.execute.assert_called_once()
|
||||
|
||||
|
||||
async def test_contact_form_invalid_email(anon_client):
|
||||
r = await anon_client.post("/api/v1/contact", json={
|
||||
"name": "Marie",
|
||||
"email": "not-an-email",
|
||||
"message": "Hello",
|
||||
})
|
||||
assert r.status_code == 422
|
||||
assert r.json()["error"]["code"] == "VALIDATION_ERROR"
|
||||
|
||||
|
||||
async def test_contact_form_missing_field(anon_client):
|
||||
r = await anon_client.post("/api/v1/contact", json={
|
||||
"name": "Marie",
|
||||
"email": "marie@test.com",
|
||||
# message missing
|
||||
})
|
||||
assert r.status_code == 422
|
||||
114
tests/test_slot_service.py
Normal file
114
tests/test_slot_service.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Unit tests for slot generation and calendar logic — no HTTP involved."""
|
||||
import asyncio
|
||||
from datetime import date, time, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.slot_service import generate_slots, _fmt_time # type: ignore
|
||||
from app.exceptions import AppError
|
||||
|
||||
|
||||
def make_mock_db(schedule=None, blocked=None):
|
||||
db = AsyncMock()
|
||||
db.transaction = MagicMock(return_value=_TxCtx())
|
||||
db.fetch = AsyncMock(side_effect=lambda q, *a: _route_fetch(q, schedule, blocked))
|
||||
db.fetchval = AsyncMock(return_value=None)
|
||||
db.execute = AsyncMock(return_value="INSERT 1")
|
||||
return db
|
||||
|
||||
|
||||
def _route_fetch(query, schedule, blocked):
|
||||
if "weekly_schedule" in query:
|
||||
return schedule or []
|
||||
if "blocked_dates" in query:
|
||||
return blocked or []
|
||||
return []
|
||||
|
||||
|
||||
class _TxCtx:
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self, *a): return False
|
||||
|
||||
|
||||
MONDAY = 0 # weekday() value for Monday
|
||||
|
||||
SCHED_ROW = {
|
||||
"day_of_week": MONDAY,
|
||||
"start_time": time(9, 0),
|
||||
"end_time": time(11, 0),
|
||||
"slot_duration_minutes": 60,
|
||||
}
|
||||
|
||||
|
||||
async def test_generate_slots_creates_correct_count():
|
||||
"""Mon 09:00–11:00 with 60-min slots → 2 slots per Monday."""
|
||||
db = make_mock_db(schedule=[SCHED_ROW])
|
||||
|
||||
# Find the next Monday
|
||||
today = date.today()
|
||||
days_ahead = (7 - today.weekday()) % 7 or 7
|
||||
next_monday = today + timedelta(days=days_ahead)
|
||||
|
||||
db.fetchval = AsyncMock(return_value=None) # no existing slots
|
||||
|
||||
created = await generate_slots(db, next_monday, next_monday)
|
||||
assert created == 2
|
||||
|
||||
|
||||
async def test_generate_slots_skips_blocked_dates():
|
||||
"""A blocked Monday produces 0 slots."""
|
||||
today = date.today()
|
||||
days_ahead = (7 - today.weekday()) % 7 or 7
|
||||
next_monday = today + timedelta(days=days_ahead)
|
||||
|
||||
blocked = [{"date": next_monday}]
|
||||
db = make_mock_db(schedule=[SCHED_ROW], blocked=blocked)
|
||||
db.fetchval = AsyncMock(return_value=None)
|
||||
|
||||
created = await generate_slots(db, next_monday, next_monday)
|
||||
assert created == 0
|
||||
|
||||
|
||||
async def test_generate_slots_skips_existing_slots():
|
||||
"""If a slot already exists, it is not duplicated."""
|
||||
today = date.today()
|
||||
days_ahead = (7 - today.weekday()) % 7 or 7
|
||||
next_monday = today + timedelta(days=days_ahead)
|
||||
|
||||
db = make_mock_db(schedule=[SCHED_ROW])
|
||||
db.fetchval = AsyncMock(return_value=1) # all slots already exist
|
||||
|
||||
created = await generate_slots(db, next_monday, next_monday)
|
||||
assert created == 0
|
||||
|
||||
|
||||
async def test_generate_slots_range_over_90_days_rejected():
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
db = AsyncMock()
|
||||
await generate_slots(db, date(2026, 1, 1), date(2026, 5, 1))
|
||||
assert exc_info.value.code == "RANGE_TOO_LARGE"
|
||||
|
||||
|
||||
async def test_generate_slots_no_schedule_raises():
|
||||
db = make_mock_db(schedule=[])
|
||||
with pytest.raises(AppError) as exc_info:
|
||||
await generate_slots(db, date(2026, 6, 1), date(2026, 6, 7))
|
||||
assert exc_info.value.code == "NO_SCHEDULE"
|
||||
|
||||
|
||||
# ── Time formatting ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_fmt_time_strips_seconds():
|
||||
from datetime import time as t
|
||||
assert _fmt_time(t(10, 0, 0)) == "10:00"
|
||||
assert _fmt_time(t(9, 30)) == "09:30"
|
||||
|
||||
|
||||
def test_fmt_time_string_input():
|
||||
assert _fmt_time("14:30:00") == "14:30"
|
||||
assert _fmt_time("08:00") == "08:00"
|
||||
|
||||
|
||||
def test_fmt_time_none():
|
||||
assert _fmt_time(None) == ""
|
||||
Reference in New Issue
Block a user