Initial Commit

This commit is contained in:
belviskhoremk
2026-05-12 00:34:21 +00:00
commit d2dc43b16f
57 changed files with 6056 additions and 0 deletions

0
tests/__init__.py Normal file
View File

202
tests/conftest.py Normal file
View 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()

View 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
View 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
View 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

View 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
View 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
View 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
View 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

View 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
View 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:0011: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) == ""