diff --git a/tests/test_admin_settings_customers.py b/tests/test_admin_settings_customers.py new file mode 100644 index 0000000..6fb5f44 --- /dev/null +++ b/tests/test_admin_settings_customers.py @@ -0,0 +1,128 @@ +""" +Tests for admin settings and admin customers endpoints. +""" +from unittest.mock import AsyncMock +from tests.conftest import USER_ID + + +SAMPLE_SETTING = { + "key": "default_booking_price", + "value": 0, + "updated_at": "2026-01-01T00:00:00", +} + +SAMPLE_CUSTOMER = { + "id": USER_ID, + "email": "client@test.com", + "full_name": "Marie Dupont", + "phone": "+49123456789", + "is_blocked": False, + "created_at": "2026-01-01T00:00:00", + "orders_count": 2, + "bookings_count": 1, + "total_spent": 378.0, +} + + +# ── Admin settings ──────────────────────────────────────────────────────────── + +async def test_get_settings_returns_list(admin_client, mock_db): + mock_db.fetch = AsyncMock(return_value=[SAMPLE_SETTING]) + r = await admin_client.get("/api/v1/admin/settings") + assert r.status_code == 200 + body = r.json() + assert body["success"] is True + assert isinstance(body["data"], list) + assert body["data"][0]["key"] == "default_booking_price" + + +async def test_get_settings_empty_table(admin_client, mock_db): + """Frontend must handle an empty settings list gracefully.""" + mock_db.fetch = AsyncMock(return_value=[]) + r = await admin_client.get("/api/v1/admin/settings") + assert r.status_code == 200 + assert r.json()["data"] == [] + + +async def test_update_setting_upsert(admin_client, mock_db): + saved = {**SAMPLE_SETTING, "value": 50, "updated_at": "2026-05-21T10:00:00"} + mock_db.fetchrow = AsyncMock(return_value=saved) + r = await admin_client.put("/api/v1/admin/settings/default_booking_price", json={"value": 50}) + assert r.status_code == 200 + body = r.json() + assert body["success"] is True + assert body["data"]["key"] == "default_booking_price" + + +async def test_update_setting_zero_value(admin_client, mock_db): + """0 is a valid value (= free appointments).""" + saved = {**SAMPLE_SETTING, "value": 0} + mock_db.fetchrow = AsyncMock(return_value=saved) + r = await admin_client.put("/api/v1/admin/settings/default_booking_price", json={"value": 0}) + assert r.status_code == 200 + + +async def test_settings_requires_admin(auth_client): + r = await auth_client.get("/api/v1/admin/settings") + assert r.status_code == 403 + + +async def test_settings_requires_auth(anon_client): + r = await anon_client.get("/api/v1/admin/settings") + assert r.status_code == 401 + + +# ── Admin customers ─────────────────────────────────────────────────────────── + +async def test_list_customers(admin_client, mock_db): + mock_db.fetchval = AsyncMock(return_value=1) + mock_db.fetch = AsyncMock(return_value=[SAMPLE_CUSTOMER]) + r = await admin_client.get("/api/v1/admin/customers") + assert r.status_code == 200 + body = r.json() + assert body["success"] is True + data = body["data"] + assert len(data) == 1 + assert data[0]["full_name"] == "Marie Dupont" + assert data[0]["orders_count"] == 2 + + +async def test_list_customers_empty(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/customers") + assert r.status_code == 200 + assert r.json()["data"] == [] + + +async def test_list_customers_search(admin_client, mock_db): + mock_db.fetchval = AsyncMock(return_value=1) + mock_db.fetch = AsyncMock(return_value=[SAMPLE_CUSTOMER]) + r = await admin_client.get("/api/v1/admin/customers?search=marie") + assert r.status_code == 200 + + +async def test_block_customer(admin_client, mock_db): + blocked = {**SAMPLE_CUSTOMER, "is_blocked": True} + mock_db.fetchrow = AsyncMock(return_value=blocked) + r = await admin_client.patch(f"/api/v1/admin/customers/{USER_ID}", json={"is_blocked": True}) + assert r.status_code == 200 + assert r.json()["data"]["is_blocked"] is True + + +async def test_unblock_customer(admin_client, mock_db): + unblocked = {**SAMPLE_CUSTOMER, "is_blocked": False} + mock_db.fetchrow = AsyncMock(return_value=unblocked) + r = await admin_client.patch(f"/api/v1/admin/customers/{USER_ID}", json={"is_blocked": False}) + assert r.status_code == 200 + assert r.json()["data"]["is_blocked"] is False + + +async def test_customers_requires_admin(auth_client): + r = await auth_client.get("/api/v1/admin/customers") + assert r.status_code == 403 + + +async def test_customers_requires_auth(anon_client): + r = await anon_client.get("/api/v1/admin/customers") + assert r.status_code == 401 diff --git a/tests/test_auth.py b/tests/test_auth.py index 99d137f..65e5121 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -43,8 +43,11 @@ 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() + with patch("app.services.auth_service._admin_client") as mock_admin, \ + patch("app.services.auth_service._client") as mock_client: + user = MagicMock(); user.id = "11111111-1111-1111-1111-111111111111" + mock_admin.return_value.auth.admin.create_user.return_value = MagicMock(user=user) + mock_client.return_value.auth.sign_in_with_password.return_value = _supabase_session() r = await anon_client.post("/api/v1/auth/register", json={ "email": "new@test.com", "password": "password123", @@ -59,8 +62,11 @@ 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() + with patch("app.services.auth_service._admin_client") as mock_admin, \ + patch("app.services.auth_service._client") as mock_client: + user = MagicMock(); user.id = "11111111-1111-1111-1111-111111111111" + mock_admin.return_value.auth.admin.create_user.return_value = MagicMock(user=user) + mock_client.return_value.auth.sign_in_with_password.return_value = _supabase_session() r = await anon_client.post("/api/v1/auth/register", json={ "email": "new2@test.com", "password": "password123", diff --git a/tests/test_booking_email_resilience.py b/tests/test_booking_email_resilience.py new file mode 100644 index 0000000..36c5d37 --- /dev/null +++ b/tests/test_booking_email_resilience.py @@ -0,0 +1,77 @@ +""" +Tests that booking status updates succeed even when the email service fails. +This covers the fix: email send is wrapped in try/except so SMTP failure +does not block the booking status change. +""" +from unittest.mock import AsyncMock, patch +from tests.conftest import SAMPLE_BOOKING, BOOKING_ID, ADMIN_ID + + +async def test_confirm_booking_succeeds_when_email_fails(admin_client, mock_db): + """Admin confirming a booking must succeed even if the email service raises.""" + mock_db.fetchrow = AsyncMock(side_effect=[ + SAMPLE_BOOKING, # UPDATE bookings RETURNING * + {"email": "client@test.com"}, # SELECT email FROM profiles + {"date": "2026-06-01", "start_time": "10:00:00"}, # SELECT from time_slots + SAMPLE_BOOKING, # get_booking at the end + ]) + mock_db.execute = AsyncMock(return_value="INSERT 1") + + with patch("app.services.email_service.send_booking_confirmed", + new_callable=AsyncMock, side_effect=Exception("SMTP unavailable")): + r = await admin_client.patch( + f"/api/v1/admin/bookings/{BOOKING_ID}", + json={"status": "confirmed"}, + ) + + # Must succeed despite the email failure + assert r.status_code == 200 + assert r.json()["success"] is True + + +async def test_cancel_booking_succeeds_when_email_fails(admin_client, mock_db): + """Same resilience for cancellation emails.""" + pending = {**SAMPLE_BOOKING, "status": "pending"} + mock_db.fetchrow = AsyncMock(side_effect=[ + pending, + {"email": "client@test.com"}, + {"date": "2026-06-01", "start_time": "10:00:00"}, + pending, + ]) + mock_db.execute = AsyncMock(return_value="INSERT 1") + + with patch("app.services.email_service.send_booking_cancelled", + new_callable=AsyncMock, side_effect=Exception("SMTP unavailable")): + r = await admin_client.patch( + f"/api/v1/admin/bookings/{BOOKING_ID}", + json={"status": "cancelled"}, + ) + + assert r.status_code == 200 + assert r.json()["success"] is True + + +async def test_confirm_booking_with_no_email_address(admin_client, mock_db): + """Booking with no guest_email and no user_id must still update successfully.""" + no_email_booking = {**SAMPLE_BOOKING, "guest_email": None, "user_id": None} + mock_db.fetchrow = AsyncMock(side_effect=[ + no_email_booking, # UPDATE RETURNING * + no_email_booking, # get_booking at the end + ]) + mock_db.execute = AsyncMock(return_value="INSERT 1") + + r = await admin_client.patch( + f"/api/v1/admin/bookings/{BOOKING_ID}", + json={"status": "confirmed"}, + ) + assert r.status_code == 200 + + +async def test_confirm_booking_not_found(admin_client, mock_db): + mock_db.fetchrow = AsyncMock(return_value=None) + r = await admin_client.patch( + f"/api/v1/admin/bookings/00000000-0000-0000-0000-000000000000", + json={"status": "confirmed"}, + ) + assert r.status_code == 404 + assert r.json()["error"]["code"] == "BOOKING_NOT_FOUND" diff --git a/tests/test_bookings.py b/tests/test_bookings.py index 565c286..9bc3f0c 100644 --- a/tests/test_bookings.py +++ b/tests/test_bookings.py @@ -25,10 +25,9 @@ async def test_get_slots_missing_date_param(anon_client): 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 + SAMPLE_SLOT, # slot availability check + SAMPLE_BOOKING, # INSERT booking RETURNING * ]) - mock_db.fetchval = AsyncMock(return_value=50.0) # default booking price r = await anon_client.post("/api/v1/bookings", json={ "slot_id": SLOT_ID, @@ -40,7 +39,6 @@ async def test_guest_booking_success(anon_client, mock_db): 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"] diff --git a/tests/test_orders.py b/tests/test_orders.py index caebae3..609ab36 100644 --- a/tests/test_orders.py +++ b/tests/test_orders.py @@ -25,8 +25,8 @@ SAMPLE_ORDER_ROW = { 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 + SAMPLE_PRODUCT_ROW, # product lookup + {"id": ORDER_ID, **SAMPLE_ORDER_ROW}, # INSERT order RETURNING * ]) mock_db.execute = AsyncMock(return_value="UPDATE 1") @@ -36,7 +36,6 @@ async def test_create_order_success(auth_client, mock_db): 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 diff --git a/tests/test_register_flow.py b/tests/test_register_flow.py new file mode 100644 index 0000000..a64cb1a --- /dev/null +++ b/tests/test_register_flow.py @@ -0,0 +1,116 @@ +""" +Tests for the updated registration flow: +- Uses admin API (create_user with email_confirm=True) instead of sign_up +- Immediately signs in after creation so a session is always returned +- full_name / name field resolved correctly +""" +from unittest.mock import AsyncMock, MagicMock, patch + + +def _admin_create_result(user_id="11111111-1111-1111-1111-111111111111"): + user = MagicMock() + user.id = user_id + result = MagicMock() + result.user = user + return result + + +def _sign_in_result(access_token="tok_access", refresh_token="tok_refresh"): + session = MagicMock() + session.access_token = access_token + session.refresh_token = refresh_token + session.expires_in = 3600 + result = MagicMock() + result.session = session + return result + + +async def test_register_returns_tokens_immediately(anon_client, mock_db): + """Registration must always return access_token — no email confirmation delay.""" + mock_db.execute = AsyncMock(return_value="INSERT 1") + + with patch("app.services.auth_service._admin_client") as mock_admin, \ + patch("app.services.auth_service._client") as mock_client: + mock_admin.return_value.auth.admin.create_user.return_value = _admin_create_result() + mock_client.return_value.auth.sign_in_with_password.return_value = _sign_in_result() + + r = await anon_client.post("/api/v1/auth/register", json={ + "email": "new@test.com", + "password": "password123", + "name": "Marie Dupont", + }) + + assert r.status_code == 201 + body = r.json() + assert body["success"] is True + assert "access_token" in body["data"] + assert "refresh_token" in body["data"] + assert body["data"]["token_type"] == "bearer" + + +async def test_register_stores_full_name_from_name_field(anon_client, mock_db): + """The `name` field sent by the frontend must be stored in the profile.""" + executed_args = [] + async def capture_execute(query, *args): + executed_args.append(args) + return "INSERT 1" + mock_db.execute = capture_execute + + with patch("app.services.auth_service._admin_client") as mock_admin, \ + patch("app.services.auth_service._client") as mock_client: + mock_admin.return_value.auth.admin.create_user.return_value = _admin_create_result() + mock_client.return_value.auth.sign_in_with_password.return_value = _sign_in_result() + + await anon_client.post("/api/v1/auth/register", json={ + "email": "new@test.com", + "password": "password123", + "name": "Marie Dupont", + }) + + # The INSERT INTO profiles call should include the name + assert any("Marie Dupont" in str(args) for args in executed_args) + + +async def test_register_stores_full_name_field(anon_client, mock_db): + """The `full_name` field is also accepted as an alias for `name`.""" + mock_db.execute = AsyncMock(return_value="INSERT 1") + + with patch("app.services.auth_service._admin_client") as mock_admin, \ + patch("app.services.auth_service._client") as mock_client: + mock_admin.return_value.auth.admin.create_user.return_value = _admin_create_result() + mock_client.return_value.auth.sign_in_with_password.return_value = _sign_in_result() + + r = await anon_client.post("/api/v1/auth/register", json={ + "email": "new2@test.com", + "password": "password123", + "full_name": "Sophie Klein", + }) + + assert r.status_code == 201 + assert "access_token" in r.json()["data"] + + +async def test_register_supabase_error_returns_400(anon_client, mock_db): + """If Supabase admin API fails, a 400 with REGISTRATION_FAILED is returned.""" + with patch("app.services.auth_service._admin_client") as mock_admin: + mock_admin.return_value.auth.admin.create_user.side_effect = Exception("Email already registered") + + r = await anon_client.post("/api/v1/auth/register", json={ + "email": "existing@test.com", + "password": "password123", + "name": "Test", + }) + + assert r.status_code == 400 + body = r.json() + assert body["success"] is False + assert body["error"]["code"] == "REGISTRATION_FAILED" + + +async def test_register_password_validation(anon_client): + r = await anon_client.post("/api/v1/auth/register", json={ + "email": "x@test.com", + "password": "short", + "name": "X", + }) + assert r.status_code == 422