Files
gw/backend/tests/test_admin_members_extended.py
ponzischeme89 6d44e05de4 v1
2026-04-18 07:23:55 +12:00

774 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 06; 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"] == []