""" Extended tests for admin-facing members and booking endpoints. Covers endpoints not exercised in test_members.py: - GET /settings/features – fetch global member feature flags - PUT /settings/features – update global member feature flags - GET /admin/members/{member_id} – fetch a single member record - GET /admin/members/{member_id}/walks – admin view of a member's walks - GET /admin/members/{member_id}/bookings – admin view of a member's bookings - GET /admin/bookings – list all bookings across members - POST /admin/bookings – create a booking on behalf of a member - PUT /admin/bookings/{booking_id} – update booking status / notes - GET /admin/messages – message history with read status - GET /admin/notifications – actionable admin notification feed - GET /admin/notifications/settings – fetch notification config - PUT /admin/notifications/settings – update notification config - POST /admin/notifications/run – manually trigger notification run """ import pytest from datetime import datetime, timezone from httpx import AsyncClient from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.audit import AuditLog from app.models.contact_lead import ContactLead from app.models.member import Member, Walk, Booking, AdminMessage from app.models.settings import SiteSettings from app.auth.password import hash_password pytestmark = [pytest.mark.asyncio, pytest.mark.members_admin] # ── Helpers ──────────────────────────────────────────────────────────────────── async def _member( db: AsyncSession, email: str = "m@example.com", claimed: bool = True, status: str = "active", ) -> Member: m = Member( email=email, first_name="Jane", last_name="Doe", phone="021 000 0000", is_claimed=claimed, is_active=True, member_status=status, hashed_password=hash_password("Password1!") if claimed else None, onboarding_data={"dog_name": "Rex"}, ) db.add(m) await db.commit() await db.refresh(m) return m async def _walk(db: AsyncSession, member_id) -> Walk: w = Walk( member_id=member_id, walked_at=datetime(2026, 3, 15, 9, 0, tzinfo=timezone.utc), service_type="pack_walk", duration_minutes=60, notes="Test walk", recorded_by="admin@example.com", ) db.add(w) await db.commit() await db.refresh(w) return w async def _booking(db: AsyncSession, member_id) -> Booking: b = Booking( member_id=member_id, service_type="pack_walk", status="pending", notes="Morning preferred", ) db.add(b) await db.commit() await db.refresh(b) return b async def _lead(db: AsyncSession, email: str = "lead@example.com") -> ContactLead: lead = ContactLead( full_name="Alex Prospect", email=email, phone="021 222 2222", suburb="Devonport", pet_name="Milo", status="invite", ) db.add(lead) await db.commit() await db.refresh(lead) return lead # ── GET /admin/members/{member_id} ───────────────────────────────────────────── async def test_admin_get_member(client: AsyncClient, admin_token: str, db_session: AsyncSession): member = await _member(db_session, "getme@example.com") resp = await client.get( f"/api/v1/admin/members/{member.id}", headers={"Authorization": f"Bearer {admin_token}"}, ) assert resp.status_code == 200 data = resp.json() assert data["id"] == str(member.id) assert data["email"] == "getme@example.com" assert data["first_name"] == "Jane" async def test_admin_get_member_not_found(client: AsyncClient, admin_token: str): import uuid resp = await client.get( f"/api/v1/admin/members/{uuid.uuid4()}", headers={"Authorization": f"Bearer {admin_token}"}, ) assert resp.status_code == 404 async def test_admin_get_member_requires_auth(client: AsyncClient, db_session: AsyncSession): member = await _member(db_session, "noauth@example.com") resp = await client.get(f"/api/v1/admin/members/{member.id}") assert resp.status_code in (401, 403) # ── GET /admin/members/{member_id}/walks ─────────────────────────────────────── async def test_admin_get_member_walks_empty(client: AsyncClient, admin_token: str, db_session: AsyncSession): member = await _member(db_session, "nowalks@example.com") resp = await client.get( f"/api/v1/admin/members/{member.id}/walks", headers={"Authorization": f"Bearer {admin_token}"}, ) assert resp.status_code == 200 assert resp.json() == [] async def test_admin_get_member_walks_with_data(client: AsyncClient, admin_token: str, db_session: AsyncSession): member = await _member(db_session, "haswalks@example.com") await _walk(db_session, member.id) await _walk(db_session, member.id) resp = await client.get( f"/api/v1/admin/members/{member.id}/walks", headers={"Authorization": f"Bearer {admin_token}"}, ) assert resp.status_code == 200 assert len(resp.json()) == 2 async def test_admin_get_member_walks_only_own(client: AsyncClient, admin_token: str, db_session: AsyncSession): """Walks returned belong only to the requested member, not all members.""" m1 = await _member(db_session, "walker1@example.com") m2 = await _member(db_session, "walker2@example.com") await _walk(db_session, m1.id) await _walk(db_session, m2.id) await _walk(db_session, m2.id) resp = await client.get( f"/api/v1/admin/members/{m1.id}/walks", headers={"Authorization": f"Bearer {admin_token}"}, ) assert resp.status_code == 200 assert len(resp.json()) == 1 # ── GET /admin/members/{member_id}/bookings ──────────────────────────────────── async def test_admin_get_member_bookings_empty(client: AsyncClient, admin_token: str, db_session: AsyncSession): member = await _member(db_session, "nobookings@example.com") resp = await client.get( f"/api/v1/admin/members/{member.id}/bookings", headers={"Authorization": f"Bearer {admin_token}"}, ) assert resp.status_code == 200 assert resp.json() == [] async def test_admin_get_member_bookings_with_data(client: AsyncClient, admin_token: str, db_session: AsyncSession): member = await _member(db_session, "hasbookings@example.com") await _booking(db_session, member.id) resp = await client.get( f"/api/v1/admin/members/{member.id}/bookings", headers={"Authorization": f"Bearer {admin_token}"}, ) assert resp.status_code == 200 data = resp.json() assert len(data) == 1 assert data[0]["service_type"] == "pack_walk" assert data[0]["status"] == "pending" async def test_admin_get_member_bookings_only_own(client: AsyncClient, admin_token: str, db_session: AsyncSession): """Bookings returned belong only to the requested member.""" m1 = await _member(db_session, "bk1@example.com") m2 = await _member(db_session, "bk2@example.com") await _booking(db_session, m1.id) await _booking(db_session, m2.id) resp = await client.get( f"/api/v1/admin/members/{m2.id}/bookings", headers={"Authorization": f"Bearer {admin_token}"}, ) assert resp.status_code == 200 assert len(resp.json()) == 1 # ── GET /admin/bookings ──────────────────────────────────────────────────────── async def test_admin_list_bookings_empty(client: AsyncClient, admin_token: str): resp = await client.get( "/api/v1/admin/bookings", headers={"Authorization": f"Bearer {admin_token}"}, ) assert resp.status_code == 200 assert resp.json() == [] async def test_admin_list_bookings_includes_member_details( client: AsyncClient, admin_token: str, db_session: AsyncSession ): """Each booking in the admin list includes the member's name and email.""" member = await _member(db_session, "listbooking@example.com") await _booking(db_session, member.id) resp = await client.get( "/api/v1/admin/bookings", headers={"Authorization": f"Bearer {admin_token}"}, ) assert resp.status_code == 200 data = resp.json() assert len(data) == 1 assert data[0]["member_email"] == "listbooking@example.com" assert data[0]["member_first_name"] == "Jane" assert data[0]["member_last_name"] == "Doe" async def test_admin_list_bookings_multiple_members( client: AsyncClient, admin_token: str, db_session: AsyncSession ): m1 = await _member(db_session, "mb1@example.com") m2 = await _member(db_session, "mb2@example.com") await _booking(db_session, m1.id) await _booking(db_session, m2.id) resp = await client.get( "/api/v1/admin/bookings", headers={"Authorization": f"Bearer {admin_token}"}, ) assert resp.status_code == 200 assert len(resp.json()) == 2 async def test_admin_list_bookings_requires_auth(client: AsyncClient): resp = await client.get("/api/v1/admin/bookings") assert resp.status_code in (401, 403) async def test_admin_bookings_feature_can_be_disabled( client: AsyncClient, admin_token: str, db_session: AsyncSession ): member = await _member(db_session, "disabledbookings@example.com") await _booking(db_session, member.id) db_session.add(SiteSettings(site_name="", bookings_enabled=False)) await db_session.commit() resp = await client.get( "/api/v1/admin/bookings", headers={"Authorization": f"Bearer {admin_token}"}, ) assert resp.status_code == 404 # ── PUT /admin/bookings/{booking_id} ────────────────────────────────────────── async def test_admin_create_booking( client: AsyncClient, admin_token: str, db_session: AsyncSession ): member = await _member(db_session, "createbooking@example.com") resp = await client.post( "/api/v1/admin/bookings", headers={"Authorization": f"Bearer {admin_token}"}, json={ "member_id": str(member.id), "service_type": "1_1_walk", "requested_date": "2026-04-09T07:00:00Z", "status": "confirmed", "admin_notes": "Created from mobile planner", }, ) assert resp.status_code == 201 data = resp.json() assert data["member_id"] == str(member.id) assert data["service_type"] == "1_1_walk" assert data["status"] == "confirmed" assert data["member_email"] == "createbooking@example.com" assert data["member_dog_name"] == "Rex" async def test_admin_create_booking_requires_auth(client: AsyncClient, db_session: AsyncSession): member = await _member(db_session, "createbooking-noauth@example.com") resp = await client.post( "/api/v1/admin/bookings", json={ "member_id": str(member.id), "service_type": "pack_walk", }, ) assert resp.status_code in (401, 403) async def test_admin_create_booking_feature_can_be_disabled( client: AsyncClient, admin_token: str, db_session: AsyncSession ): member = await _member(db_session, "createbooking-disabled@example.com") db_session.add(SiteSettings(site_name="", bookings_enabled=False)) await db_session.commit() resp = await client.post( "/api/v1/admin/bookings", headers={"Authorization": f"Bearer {admin_token}"}, json={ "member_id": str(member.id), "service_type": "pack_walk", }, ) assert resp.status_code == 404 # ── PUT /admin/bookings/{booking_id} ────────────────────────────────────────── async def test_admin_update_booking_status( client: AsyncClient, admin_token: str, db_session: AsyncSession ): member = await _member(db_session, "updatebooking@example.com") booking = await _booking(db_session, member.id) resp = await client.put( f"/api/v1/admin/bookings/{booking.id}", headers={"Authorization": f"Bearer {admin_token}"}, json={"status": "confirmed"}, ) assert resp.status_code == 200 data = resp.json() assert data["status"] == "confirmed" assert data["id"] == str(booking.id) async def test_admin_update_booking_admin_notes( client: AsyncClient, admin_token: str, db_session: AsyncSession ): member = await _member(db_session, "booknotes@example.com") booking = await _booking(db_session, member.id) resp = await client.put( f"/api/v1/admin/bookings/{booking.id}", headers={"Authorization": f"Bearer {admin_token}"}, json={"admin_notes": "Revised to afternoon slot"}, ) assert resp.status_code == 200 assert resp.json()["admin_notes"] == "Revised to afternoon slot" async def test_admin_update_booking_requested_date( client: AsyncClient, admin_token: str, db_session: AsyncSession ): member = await _member(db_session, "bookmove@example.com") booking = await _booking(db_session, member.id) moved_to = "2026-04-09T13:00:00Z" resp = await client.put( f"/api/v1/admin/bookings/{booking.id}", headers={"Authorization": f"Bearer {admin_token}"}, json={"requested_date": moved_to, "admin_notes": "Moved to PM route"}, ) assert resp.status_code == 200 data = resp.json() assert data["requested_date"] == moved_to assert data["admin_notes"] == "Moved to PM route" async def test_admin_update_booking_not_found(client: AsyncClient, admin_token: str): import uuid resp = await client.put( f"/api/v1/admin/bookings/{uuid.uuid4()}", headers={"Authorization": f"Bearer {admin_token}"}, json={"status": "confirmed"}, ) assert resp.status_code == 404 async def test_admin_update_booking_includes_member_details( client: AsyncClient, admin_token: str, db_session: AsyncSession ): """Response includes member name and email even after update.""" member = await _member(db_session, "updatebk2@example.com") booking = await _booking(db_session, member.id) resp = await client.put( f"/api/v1/admin/bookings/{booking.id}", headers={"Authorization": f"Bearer {admin_token}"}, json={"status": "cancelled"}, ) assert resp.status_code == 200 data = resp.json() assert data["member_email"] == "updatebk2@example.com" assert data["status"] == "cancelled" async def test_admin_can_archive_member(client: AsyncClient, admin_token: str, db_session: AsyncSession): member = await _member(db_session, "archive-me@example.com") resp = await client.post( f"/api/v1/admin/members/{member.id}/archive", headers={"Authorization": f"Bearer {admin_token}"}, ) assert resp.status_code == 200 data = resp.json() assert data["member_status"] == "archived" assert data["is_active"] is False async def test_admin_can_deactivate_member(client: AsyncClient, admin_token: str, db_session: AsyncSession): member = await _member(db_session, "deactivate-me@example.com") resp = await client.post( f"/api/v1/admin/members/{member.id}/deactivate", headers={"Authorization": f"Bearer {admin_token}"}, ) assert resp.status_code == 200 assert resp.json()["is_active"] is False async def test_admin_can_toggle_member_force_two_factor(client: AsyncClient, admin_token: str, db_session: AsyncSession): member = await _member(db_session, "force-toggle@example.com") resp = await client.post( f"/api/v1/admin/members/{member.id}/force-2fa", headers={"Authorization": f"Bearer {admin_token}"}, json={"enabled": True}, ) assert resp.status_code == 200 assert resp.json()["force_two_factor"] is True async def test_admin_can_reset_member_password(client: AsyncClient, admin_token: str, db_session: AsyncSession): member = await _member(db_session, "reset-password@example.com") resp = await client.post( f"/api/v1/admin/members/{member.id}/reset-password", headers={"Authorization": f"Bearer {admin_token}"}, ) assert resp.status_code == 200 assert resp.json()["is_claimed"] is False await db_session.refresh(member) assert member.hashed_password is None # ── GET /admin/notifications/settings ───────────────────────────────────────── async def test_admin_list_messages_history(client: AsyncClient, admin_token: str, db_session: AsyncSession): member = await _member(db_session, "history@example.com") message = AdminMessage( member_id=member.id, subject="Walk update", body="Tomorrow's slot is confirmed.", sent_by="admin@example.com", read_at=datetime(2026, 4, 1, 9, 30, tzinfo=timezone.utc), ) db_session.add(message) await db_session.commit() resp = await client.get( "/api/v1/admin/messages", headers={"Authorization": f"Bearer {admin_token}"}, ) assert resp.status_code == 200 data = resp.json() assert len(data) == 1 assert data[0]["member_email"] == "history@example.com" assert data[0]["subject"] == "Walk update" assert data[0]["read_at"] is not None async def test_admin_list_messages_requires_auth(client: AsyncClient): resp = await client.get("/api/v1/admin/messages") assert resp.status_code in (401, 403) async def test_admin_list_notifications(client: AsyncClient, admin_token: str, db_session: AsyncSession): member = await _member(db_session, "notify@example.com", status="pending_review") booking = await _booking(db_session, member.id) lead = await _lead(db_session, "notifylead@example.com") db_session.add_all( [ AuditLog( member_id=member.id, member_email=member.email, action_type="login", area="members/login", description="Member logged in successfully.", status="success", timestamp=datetime.now(timezone.utc), ), AuditLog( member_id=member.id, member_email=member.email, action_type="logout", area="members/logout", description="Member ended their session.", status="success", timestamp=datetime.now(timezone.utc), ), ] ) await db_session.commit() resp = await client.get( "/api/v1/admin/notifications", headers={"Authorization": f"Bearer {admin_token}"}, ) assert resp.status_code == 200 data = resp.json() assert "items" in data assert "settings" in data assert data["total"] >= 3 item_types = {item["type"] for item in data["items"]} assert "pending_booking" in item_types assert "new_lead" in item_types assert "pending_review" in item_types assert "member_login" in item_types assert "member_logout" in item_types hrefs = {item["id"]: item["href"] for item in data["items"]} assert hrefs[str(booking.id)] == "/admin/bookings" assert hrefs[str(lead.id)] == "/admin/leads" assert hrefs[str(member.id)] == f"/admin/members/{member.id}" session_hrefs = {item["type"]: item["href"] for item in data["items"] if item["type"] in {"member_login", "member_logout"}} assert session_hrefs["member_login"] == f"/admin/members/{member.id}" assert session_hrefs["member_logout"] == f"/admin/members/{member.id}" async def test_admin_list_notifications_requires_auth(client: AsyncClient): resp = await client.get("/api/v1/admin/notifications") assert resp.status_code in (401, 403) async def test_admin_get_notification_settings(client: AsyncClient, admin_token: str): resp = await client.get( "/api/v1/admin/notifications/settings", headers={"Authorization": f"Bearer {admin_token}"}, ) assert resp.status_code == 200 data = resp.json() assert "automatic_member_notifications_enabled" in data assert "nz_public_holiday_notifications_enabled" in data assert "invoice_reminder_notifications_enabled" in data assert "invoice_day_of_week" in data assert isinstance(data["invoice_day_of_week"], int) async def test_admin_get_notification_settings_requires_auth(client: AsyncClient): resp = await client.get("/api/v1/admin/notifications/settings") assert resp.status_code in (401, 403) # ── GET /settings/features ──────────────────────────────────────────────────── async def test_get_feature_settings_defaults(client: AsyncClient): resp = await client.get("/api/v1/settings/features") assert resp.status_code == 200 assert resp.json() == { "bookings_enabled": True, "walks_enabled": True, "messages_enabled": True, "two_factor_enabled": True, "audit_history_enabled": True, "experiments_enabled": True, } # ── PUT /settings/features ──────────────────────────────────────────────────── async def test_update_feature_settings(client: AsyncClient, admin_token: str): resp = await client.put( "/api/v1/settings/features", headers={"Authorization": f"Bearer {admin_token}"}, json={ "bookings_enabled": False, "walks_enabled": True, "messages_enabled": False, "two_factor_enabled": False, "audit_history_enabled": False, "experiments_enabled": False, }, ) assert resp.status_code == 200 assert resp.json() == { "bookings_enabled": False, "walks_enabled": True, "messages_enabled": False, "two_factor_enabled": False, "audit_history_enabled": False, "experiments_enabled": False, } async def test_update_feature_settings_requires_auth(client: AsyncClient): resp = await client.put( "/api/v1/settings/features", json={"bookings_enabled": False}, ) assert resp.status_code in (401, 403) async def test_get_service_pricing_defaults(client: AsyncClient): resp = await client.get("/api/v1/settings/pricing") assert resp.status_code == 200 data = resp.json()["service_pricing"] assert data["pack_walk"]["amount"] == 58.0 assert data["1_1_walk"]["amount"] == 45.0 assert data["puppy_visit"]["amount"] == 39.0 async def test_update_service_pricing(client: AsyncClient, admin_token: str): resp = await client.put( "/api/v1/settings/pricing", headers={"Authorization": f"Bearer {admin_token}"}, json={ "service_pricing": { "pack_walk": {"amount": 61, "label": "Pack Walk", "unit": "per walk"}, "1_1_walk": {"amount": 52, "label": "1-1 Walk", "unit": "per walk"}, "puppy_visit": {"amount": 44, "label": "Puppy Visit", "unit": "per visit"}, } }, ) assert resp.status_code == 200 data = resp.json()["service_pricing"] assert data["pack_walk"]["amount"] == 61.0 assert data["1_1_walk"]["amount"] == 52.0 assert data["puppy_visit"]["amount"] == 44.0 async def test_update_service_pricing_requires_auth(client: AsyncClient): resp = await client.put( "/api/v1/settings/pricing", json={"service_pricing": {"pack_walk": {"amount": 62}}}, ) assert resp.status_code in (401, 403) async def test_admin_audit_history_returns_404_when_disabled( client: AsyncClient, admin_token: str, db_session: AsyncSession, ): db_session.add(SiteSettings(site_name="", audit_history_enabled=False)) await db_session.commit() resp = await client.get( "/api/v1/admin/audit", headers={"Authorization": f"Bearer {admin_token}"}, ) assert resp.status_code == 404 # ── PUT /admin/notifications/settings ───────────────────────────────────────── async def test_admin_update_notification_settings_toggle(client: AsyncClient, admin_token: str): # Read current state get_resp = await client.get( "/api/v1/admin/notifications/settings", headers={"Authorization": f"Bearer {admin_token}"}, ) current = get_resp.json()["automatic_member_notifications_enabled"] # Flip the flag put_resp = await client.put( "/api/v1/admin/notifications/settings", headers={"Authorization": f"Bearer {admin_token}"}, json={"automatic_member_notifications_enabled": not current}, ) assert put_resp.status_code == 200 assert put_resp.json()["automatic_member_notifications_enabled"] is not current async def test_admin_update_invoice_day_of_week(client: AsyncClient, admin_token: str): resp = await client.put( "/api/v1/admin/notifications/settings", headers={"Authorization": f"Bearer {admin_token}"}, json={"invoice_day_of_week": 4}, # Friday ) assert resp.status_code == 200 assert resp.json()["invoice_day_of_week"] == 4 async def test_admin_update_invoice_day_invalid(client: AsyncClient, admin_token: str): """Day of week must be 0–6; out-of-range values are rejected.""" resp = await client.put( "/api/v1/admin/notifications/settings", headers={"Authorization": f"Bearer {admin_token}"}, json={"invoice_day_of_week": 7}, ) assert resp.status_code == 422 # ── POST /admin/notifications/run ───────────────────────────────────────────── async def test_admin_run_notifications(client: AsyncClient, admin_token: str): resp = await client.post( "/api/v1/admin/notifications/run", headers={"Authorization": f"Bearer {admin_token}"}, ) assert resp.status_code == 200 data = resp.json() assert "automatic_member_notifications_enabled" in data assert "public_holiday_messages_sent" in data assert "invoice_reminders_sent" in data assert isinstance(data["public_holiday_messages_sent"], int) assert isinstance(data["invoice_reminders_sent"], int) async def test_admin_run_notifications_requires_auth(client: AsyncClient): resp = await client.post("/api/v1/admin/notifications/run") assert resp.status_code in (401, 403) async def test_admin_can_clear_notifications(client: AsyncClient, admin_token: str, db_session: AsyncSession): member = await _member(db_session, "clearable@example.com", status="pending_review") await _booking(db_session, member.id) db_session.add( AuditLog( member_id=member.id, member_email=member.email, action_type="login", area="members/login", description="Member logged in successfully.", status="success", timestamp=datetime.now(timezone.utc), ) ) await db_session.commit() clear_resp = await client.post( "/api/v1/admin/notifications/clear", headers={"Authorization": f"Bearer {admin_token}"}, ) assert clear_resp.status_code == 200 cleared = clear_resp.json() assert cleared["items"] == [] assert cleared["total"] == 0 settings_row = (await db_session.execute(select(SiteSettings))).scalars().first() assert settings_row is not None assert settings_row.admin_notifications_cleared_before is not None feed_resp = await client.get( "/api/v1/admin/notifications", headers={"Authorization": f"Bearer {admin_token}"}, ) assert feed_resp.status_code == 200 assert feed_resp.json()["items"] == []