774 lines
28 KiB
Python
774 lines
28 KiB
Python
|
|
"""
|
|||
|
|
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"] == []
|