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"] == []
|