272 lines
11 KiB
Python
272 lines
11 KiB
Python
|
|
from datetime import datetime, timezone
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from httpx import AsyncClient
|
||
|
|
from sqlalchemy import select
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
|
|
||
|
|
from app.models.section import ContentSection
|
||
|
|
from app.models.member import AdminMessage, Booking, Member
|
||
|
|
from app.models.settings import SiteSettings
|
||
|
|
from app.services.notifications import run_automatic_notifications
|
||
|
|
from app.auth.password import hash_password
|
||
|
|
|
||
|
|
pytestmark = pytest.mark.asyncio
|
||
|
|
|
||
|
|
|
||
|
|
async def _create_member(
|
||
|
|
db: AsyncSession,
|
||
|
|
email: str,
|
||
|
|
*,
|
||
|
|
claimed: bool = False,
|
||
|
|
member_status: str | None = None,
|
||
|
|
) -> Member:
|
||
|
|
member = Member(
|
||
|
|
email=email,
|
||
|
|
first_name="Jane",
|
||
|
|
last_name="Doe",
|
||
|
|
phone="021 000 0000",
|
||
|
|
is_claimed=claimed,
|
||
|
|
is_active=True,
|
||
|
|
notifications_enabled=True,
|
||
|
|
member_status=member_status or ("active" if claimed else "invited"),
|
||
|
|
hashed_password=hash_password("Password1!") if claimed else None,
|
||
|
|
onboarding_data={"dog_name": "Buddy", "breed": "Labrador"},
|
||
|
|
)
|
||
|
|
db.add(member)
|
||
|
|
await db.commit()
|
||
|
|
await db.refresh(member)
|
||
|
|
return member
|
||
|
|
|
||
|
|
|
||
|
|
async def test_activation_sends_member_message(client: AsyncClient, admin_token: str, db_session: AsyncSession):
|
||
|
|
member = await _create_member(db_session, "notify-active@example.com", claimed=True, member_status="pending_review")
|
||
|
|
member.onboarding_completed_at = datetime.now(timezone.utc)
|
||
|
|
member.contract_signed_at = datetime.now(timezone.utc)
|
||
|
|
member.contract_signer_name = "Jane Doe"
|
||
|
|
await db_session.commit()
|
||
|
|
|
||
|
|
response = await client.post(
|
||
|
|
f"/api/v1/admin/members/{member.id}/activate",
|
||
|
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||
|
|
)
|
||
|
|
assert response.status_code == 200
|
||
|
|
|
||
|
|
result = await db_session.execute(select(AdminMessage).where(AdminMessage.member_id == member.id))
|
||
|
|
messages = result.scalars().all()
|
||
|
|
assert len(messages) == 1
|
||
|
|
assert messages[0].subject == "Your Goodwalk members account is now active"
|
||
|
|
|
||
|
|
|
||
|
|
async def test_activation_respects_member_notification_toggle(client: AsyncClient, admin_token: str, db_session: AsyncSession):
|
||
|
|
member = await _create_member(db_session, "notify-muted@example.com", claimed=True, member_status="pending_review")
|
||
|
|
member.onboarding_completed_at = datetime.now(timezone.utc)
|
||
|
|
member.contract_signed_at = datetime.now(timezone.utc)
|
||
|
|
member.contract_signer_name = "Jane Doe"
|
||
|
|
member.notifications_enabled = False
|
||
|
|
await db_session.commit()
|
||
|
|
|
||
|
|
response = await client.post(
|
||
|
|
f"/api/v1/admin/members/{member.id}/activate",
|
||
|
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||
|
|
)
|
||
|
|
assert response.status_code == 200
|
||
|
|
|
||
|
|
result = await db_session.execute(select(AdminMessage).where(AdminMessage.member_id == member.id))
|
||
|
|
assert result.scalars().first() is None
|
||
|
|
|
||
|
|
|
||
|
|
async def test_global_notification_toggle_suppresses_activation_message(client: AsyncClient, admin_token: str, db_session: AsyncSession):
|
||
|
|
member = await _create_member(db_session, "notify-global-off@example.com", claimed=True, member_status="pending_review")
|
||
|
|
member.onboarding_completed_at = datetime.now(timezone.utc)
|
||
|
|
member.contract_signed_at = datetime.now(timezone.utc)
|
||
|
|
member.contract_signer_name = "Jane Doe"
|
||
|
|
db_session.add(
|
||
|
|
SiteSettings(
|
||
|
|
site_name="Goodwalk",
|
||
|
|
automatic_member_notifications_enabled=False,
|
||
|
|
nz_public_holiday_notifications_enabled=True,
|
||
|
|
invoice_reminder_notifications_enabled=True,
|
||
|
|
invoice_day_of_week=1,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
await db_session.commit()
|
||
|
|
|
||
|
|
response = await client.post(
|
||
|
|
f"/api/v1/admin/members/{member.id}/activate",
|
||
|
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||
|
|
)
|
||
|
|
assert response.status_code == 200
|
||
|
|
|
||
|
|
result = await db_session.execute(select(AdminMessage).where(AdminMessage.member_id == member.id))
|
||
|
|
assert result.scalars().first() is None
|
||
|
|
|
||
|
|
|
||
|
|
async def test_booking_confirmation_sends_member_message(client: AsyncClient, admin_token: str, db_session: AsyncSession):
|
||
|
|
member = await _create_member(db_session, "bookingnotify@example.com", claimed=True, member_status="active")
|
||
|
|
booking = Booking(
|
||
|
|
member_id=member.id,
|
||
|
|
service_type="pack_walk",
|
||
|
|
status="pending",
|
||
|
|
requested_date=datetime(2026, 4, 7, 9, 0, tzinfo=timezone.utc),
|
||
|
|
)
|
||
|
|
db_session.add(booking)
|
||
|
|
await db_session.commit()
|
||
|
|
await db_session.refresh(booking)
|
||
|
|
|
||
|
|
response = await client.put(
|
||
|
|
f"/api/v1/admin/bookings/{booking.id}",
|
||
|
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||
|
|
json={"status": "confirmed"},
|
||
|
|
)
|
||
|
|
assert response.status_code == 200
|
||
|
|
|
||
|
|
result = await db_session.execute(select(AdminMessage).where(AdminMessage.member_id == member.id))
|
||
|
|
messages = result.scalars().all()
|
||
|
|
assert len(messages) == 1
|
||
|
|
assert "confirmed" in messages[0].subject.lower()
|
||
|
|
|
||
|
|
|
||
|
|
async def test_activation_uses_custom_template_content(client: AsyncClient, admin_token: str, db_session: AsyncSession):
|
||
|
|
member = await _create_member(db_session, "custom-active@example.com", claimed=True, member_status="pending_review")
|
||
|
|
member.onboarding_completed_at = datetime.now(timezone.utc)
|
||
|
|
member.contract_signed_at = datetime.now(timezone.utc)
|
||
|
|
member.contract_signer_name = "Jane Doe"
|
||
|
|
db_session.add(
|
||
|
|
ContentSection(
|
||
|
|
key="notifications.automaticMessages",
|
||
|
|
data={
|
||
|
|
"templates": {
|
||
|
|
"member_activated": {
|
||
|
|
"subject": "Welcome live, {{member_first_name}}",
|
||
|
|
"body": "{{member_first_name}}, your members area is unlocked.",
|
||
|
|
}
|
||
|
|
}
|
||
|
|
},
|
||
|
|
)
|
||
|
|
)
|
||
|
|
await db_session.commit()
|
||
|
|
|
||
|
|
response = await client.post(
|
||
|
|
f"/api/v1/admin/members/{member.id}/activate",
|
||
|
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||
|
|
)
|
||
|
|
assert response.status_code == 200
|
||
|
|
|
||
|
|
result = await db_session.execute(select(AdminMessage).where(AdminMessage.member_id == member.id))
|
||
|
|
message = result.scalars().one()
|
||
|
|
assert message.subject == "Welcome live, Jane"
|
||
|
|
assert message.body == "Jane, your members area is unlocked."
|
||
|
|
|
||
|
|
|
||
|
|
async def test_automatic_notifications_send_and_dedupe(db_session: AsyncSession):
|
||
|
|
enabled_member = await _create_member(db_session, "holidaymember@example.com", claimed=True, member_status="active")
|
||
|
|
muted_member = await _create_member(db_session, "holidaymuted@example.com", claimed=True, member_status="active")
|
||
|
|
muted_member.notifications_enabled = False
|
||
|
|
db_session.add(
|
||
|
|
SiteSettings(
|
||
|
|
site_name="Goodwalk",
|
||
|
|
automatic_member_notifications_enabled=True,
|
||
|
|
nz_public_holiday_notifications_enabled=True,
|
||
|
|
invoice_reminder_notifications_enabled=False,
|
||
|
|
invoice_day_of_week=1,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
await db_session.commit()
|
||
|
|
|
||
|
|
summary = await run_automatic_notifications(db_session, now=datetime(2026, 7, 10, 0, 30, tzinfo=timezone.utc))
|
||
|
|
await db_session.commit()
|
||
|
|
assert summary.public_holiday_messages_sent == 1
|
||
|
|
|
||
|
|
result = await db_session.execute(select(AdminMessage).where(AdminMessage.member_id == enabled_member.id))
|
||
|
|
first_run_messages = result.scalars().all()
|
||
|
|
assert len(first_run_messages) == 1
|
||
|
|
assert "public holiday" in first_run_messages[0].subject.lower()
|
||
|
|
|
||
|
|
second_summary = await run_automatic_notifications(db_session, now=datetime(2026, 7, 10, 10, 0, tzinfo=timezone.utc))
|
||
|
|
await db_session.commit()
|
||
|
|
assert second_summary.public_holiday_messages_sent == 0
|
||
|
|
|
||
|
|
|
||
|
|
async def test_public_holiday_automation_uses_custom_template(db_session: AsyncSession):
|
||
|
|
member = await _create_member(db_session, "holiday-template@example.com", claimed=True, member_status="active")
|
||
|
|
db_session.add(
|
||
|
|
SiteSettings(
|
||
|
|
site_name="Goodwalk",
|
||
|
|
automatic_member_notifications_enabled=True,
|
||
|
|
nz_public_holiday_notifications_enabled=True,
|
||
|
|
invoice_reminder_notifications_enabled=False,
|
||
|
|
invoice_day_of_week=1,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
db_session.add(
|
||
|
|
ContentSection(
|
||
|
|
key="notifications.publicHolidays",
|
||
|
|
data={
|
||
|
|
"subject": "Heads up for {{holiday_name}}",
|
||
|
|
"body": "{{member_first_name}}, service may change on {{holiday_name}}.",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
)
|
||
|
|
await db_session.commit()
|
||
|
|
|
||
|
|
summary = await run_automatic_notifications(db_session, now=datetime(2026, 7, 10, 0, 30, tzinfo=timezone.utc))
|
||
|
|
await db_session.commit()
|
||
|
|
|
||
|
|
assert summary.public_holiday_messages_sent == 1
|
||
|
|
result = await db_session.execute(select(AdminMessage).where(AdminMessage.member_id == member.id))
|
||
|
|
message = result.scalars().one()
|
||
|
|
assert message.subject == "Heads up for Matariki"
|
||
|
|
assert message.body == "Jane, service may change on Matariki."
|
||
|
|
|
||
|
|
|
||
|
|
async def test_invoice_automation_uses_custom_template(db_session: AsyncSession):
|
||
|
|
member = await _create_member(db_session, "invoice-template@example.com", claimed=True, member_status="active")
|
||
|
|
db_session.add(
|
||
|
|
SiteSettings(
|
||
|
|
site_name="Goodwalk",
|
||
|
|
automatic_member_notifications_enabled=True,
|
||
|
|
nz_public_holiday_notifications_enabled=False,
|
||
|
|
invoice_reminder_notifications_enabled=True,
|
||
|
|
invoice_day_of_week=1,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
db_session.add(
|
||
|
|
ContentSection(
|
||
|
|
key="notifications.invoiceReminders",
|
||
|
|
data={
|
||
|
|
"subject": "Invoices go out on {{weekday_label}}",
|
||
|
|
"body": "Preview date: {{invoice_date_label}}.",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
)
|
||
|
|
await db_session.commit()
|
||
|
|
|
||
|
|
summary = await run_automatic_notifications(db_session, now=datetime(2026, 4, 7, 1, 0, tzinfo=timezone.utc))
|
||
|
|
await db_session.commit()
|
||
|
|
|
||
|
|
assert summary.invoice_reminders_sent == 1
|
||
|
|
result = await db_session.execute(select(AdminMessage).where(AdminMessage.member_id == member.id))
|
||
|
|
message = result.scalars().one()
|
||
|
|
assert message.subject == "Invoices go out on Tuesday"
|
||
|
|
assert message.body == "Preview date: Tuesday 7 April."
|
||
|
|
|
||
|
|
|
||
|
|
async def test_global_automatic_notification_toggle_suppresses_automation(db_session: AsyncSession):
|
||
|
|
await _create_member(db_session, "invoice@example.com", claimed=True, member_status="active")
|
||
|
|
db_session.add(
|
||
|
|
SiteSettings(
|
||
|
|
site_name="Goodwalk",
|
||
|
|
automatic_member_notifications_enabled=False,
|
||
|
|
nz_public_holiday_notifications_enabled=True,
|
||
|
|
invoice_reminder_notifications_enabled=True,
|
||
|
|
invoice_day_of_week=1,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
await db_session.commit()
|
||
|
|
|
||
|
|
summary = await run_automatic_notifications(db_session, now=datetime(2026, 4, 7, 1, 0, tzinfo=timezone.utc))
|
||
|
|
await db_session.commit()
|
||
|
|
assert summary.automatic_member_notifications_enabled is False
|
||
|
|
assert summary.invoice_reminders_sent == 0
|