170 lines
6.0 KiB
Python
170 lines
6.0 KiB
Python
|
|
"""Proposal engine end-to-end against the mock provider (§9, M1)."""
|
||
|
|
|
||
|
|
from datetime import UTC, datetime, timedelta
|
||
|
|
from zoneinfo import ZoneInfo
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from sqlalchemy import select
|
||
|
|
|
||
|
|
from gogo.domain import BookingStatus, Slot
|
||
|
|
from gogo.models import ActionToken
|
||
|
|
from gogo.proposals import engine
|
||
|
|
|
||
|
|
TZ = ZoneInfo("Europe/Sarajevo")
|
||
|
|
|
||
|
|
|
||
|
|
def slot(days_ahead: int = 2, hour: int = 17) -> Slot:
|
||
|
|
base = datetime.now(TZ).replace(hour=hour, minute=0, second=0, microsecond=0)
|
||
|
|
start = base + timedelta(days=days_ahead)
|
||
|
|
return Slot(start=start, end=start + timedelta(minutes=45))
|
||
|
|
|
||
|
|
|
||
|
|
async def create(session, tenant, **kw):
|
||
|
|
defaults = dict(
|
||
|
|
source="voice",
|
||
|
|
client_name="Amra Hodžić",
|
||
|
|
client_phone="+38765123456",
|
||
|
|
service_name_raw="šišanje i feniranje",
|
||
|
|
slots=[slot()],
|
||
|
|
summary="Klijentica želi šišanje i feniranje.",
|
||
|
|
)
|
||
|
|
defaults.update(kw)
|
||
|
|
return await engine.create_booking_request(session, tenant, **defaults)
|
||
|
|
|
||
|
|
|
||
|
|
async def test_create_sends_email_with_actions_and_ics(session, tenant, emails, sms):
|
||
|
|
req, result = await create(session, tenant)
|
||
|
|
await session.commit()
|
||
|
|
|
||
|
|
assert result.ok
|
||
|
|
assert req.status == BookingStatus.pending.value
|
||
|
|
assert req.expires_at is not None
|
||
|
|
|
||
|
|
assert len(emails) == 1
|
||
|
|
mail = emails[0]
|
||
|
|
assert mail.to == ["merima@example.ba"]
|
||
|
|
assert "Novi zahtjev za termin" in mail.subject
|
||
|
|
assert "Amra Hodžić" in mail.subject
|
||
|
|
assert "+38765123456" in mail.text
|
||
|
|
assert "RIJEŠENO" in mail.text
|
||
|
|
assert "POTVRDI" in mail.text
|
||
|
|
assert "ODBIJ" in mail.text
|
||
|
|
# .ics attachment with METHOD:REQUEST
|
||
|
|
assert mail.attachments and mail.attachments[0][0] == "termin.ics"
|
||
|
|
assert b"METHOD:REQUEST" in mail.attachments[0][2]
|
||
|
|
assert b"BEGIN:VEVENT" in mail.attachments[0][2]
|
||
|
|
|
||
|
|
# action tokens persisted: resolve + confirm(1 slot) + reject
|
||
|
|
tokens = (await session.execute(select(ActionToken))).scalars().all()
|
||
|
|
assert {t.action for t in tokens} == {"resolve", "confirm", "reject"}
|
||
|
|
|
||
|
|
# no client SMS by default (sms_request_received off)
|
||
|
|
assert sms == []
|
||
|
|
|
||
|
|
|
||
|
|
async def test_request_received_sms_when_enabled(session, tenant, emails, sms):
|
||
|
|
tenant.sms_request_received = True
|
||
|
|
await create(session, tenant)
|
||
|
|
await session.commit()
|
||
|
|
assert len(sms) == 1
|
||
|
|
assert sms[0][0] == "+38765123456"
|
||
|
|
assert "Primili smo vaš zahtjev" in sms[0][1]
|
||
|
|
|
||
|
|
|
||
|
|
async def test_resolve_sends_no_sms(session, tenant, emails, sms):
|
||
|
|
req, _ = await create(session, tenant)
|
||
|
|
await engine.resolve_request(session, tenant, req)
|
||
|
|
await session.commit()
|
||
|
|
assert req.status == BookingStatus.resolved_by_owner.value
|
||
|
|
assert req.resolved_at is not None
|
||
|
|
assert sms == [] # primary flow: owner contacted client directly, Gogo sends nothing
|
||
|
|
|
||
|
|
|
||
|
|
async def test_confirm_sends_confirmation_sms(session, tenant, emails, sms):
|
||
|
|
s = slot()
|
||
|
|
req, _ = await create(session, tenant, slots=[s])
|
||
|
|
ok = await engine.confirm_request(session, tenant, req, s)
|
||
|
|
await session.commit()
|
||
|
|
assert ok
|
||
|
|
assert req.status == BookingStatus.confirmed.value
|
||
|
|
assert len(sms) == 1
|
||
|
|
to, body = sms[0]
|
||
|
|
assert to == "+38765123456"
|
||
|
|
assert "Potvrđen termin" in body
|
||
|
|
assert "Salon Merima" in body
|
||
|
|
|
||
|
|
|
||
|
|
async def test_confirm_recheck_busy_blocks_transition(
|
||
|
|
session, tenant, emails, sms, clean_mock_provider
|
||
|
|
):
|
||
|
|
s = slot()
|
||
|
|
req, _ = await create(session, tenant, slots=[s])
|
||
|
|
# is_slot_free is only defined for providers that compute availability;
|
||
|
|
# mock has no is_slot_free → recheck is skipped. Simulate a gcal-style
|
||
|
|
# provider recheck by attaching one.
|
||
|
|
from gogo.scheduling.mock import MockProvider
|
||
|
|
|
||
|
|
async def is_slot_free(self, service_id, slot_):
|
||
|
|
return False
|
||
|
|
|
||
|
|
MockProvider.is_slot_free = is_slot_free
|
||
|
|
try:
|
||
|
|
ok = await engine.confirm_request(session, tenant, req, s, recheck=True)
|
||
|
|
finally:
|
||
|
|
del MockProvider.is_slot_free
|
||
|
|
assert not ok
|
||
|
|
assert req.status == BookingStatus.pending.value
|
||
|
|
assert sms == []
|
||
|
|
|
||
|
|
|
||
|
|
async def test_reject_sends_sms_with_custom_text(session, tenant, emails, sms):
|
||
|
|
req, _ = await create(session, tenant)
|
||
|
|
await engine.reject_request(session, tenant, req, custom_sms="Nažalost ne može ovaj termin.")
|
||
|
|
await session.commit()
|
||
|
|
assert req.status == BookingStatus.rejected.value
|
||
|
|
assert sms == [("+38765123456", "Nažalost ne može ovaj termin.")]
|
||
|
|
|
||
|
|
|
||
|
|
async def test_transitions_are_exclusive(session, tenant, emails, sms):
|
||
|
|
req, _ = await create(session, tenant)
|
||
|
|
await engine.resolve_request(session, tenant, req)
|
||
|
|
with pytest.raises(engine.TransitionError):
|
||
|
|
await engine.reject_request(session, tenant, req)
|
||
|
|
# idempotent replay of same action is fine
|
||
|
|
await engine.resolve_request(session, tenant, req)
|
||
|
|
assert req.status == BookingStatus.resolved_by_owner.value
|
||
|
|
|
||
|
|
|
||
|
|
async def test_expiry_job_expires_and_sends_apology(session, tenant, emails, sms):
|
||
|
|
req, _ = await create(session, tenant)
|
||
|
|
req.expires_at = datetime.now(UTC) - timedelta(minutes=1)
|
||
|
|
await session.flush()
|
||
|
|
expired = await engine.process_expirations(session)
|
||
|
|
await session.commit()
|
||
|
|
assert expired == 1
|
||
|
|
assert req.status == BookingStatus.expired.value
|
||
|
|
assert len(sms) == 1
|
||
|
|
assert "pozovite nas ponovo" in sms[0][1].lower() or "pozovite" in sms[0][1].lower()
|
||
|
|
|
||
|
|
|
||
|
|
async def test_reminder_email_at_half_ttl(session, tenant, emails, sms):
|
||
|
|
req, _ = await create(session, tenant)
|
||
|
|
emails.clear()
|
||
|
|
# 24h TTL → reminder when <= 12h remain
|
||
|
|
req.expires_at = datetime.now(UTC) + timedelta(hours=11)
|
||
|
|
await session.flush()
|
||
|
|
expired = await engine.process_expirations(session)
|
||
|
|
await session.commit()
|
||
|
|
assert expired == 0
|
||
|
|
assert req.reminder_sent
|
||
|
|
assert len(emails) == 1
|
||
|
|
assert "Podsjetnik" in emails[0].subject
|
||
|
|
# second run must not re-send
|
||
|
|
await engine.process_expirations(session)
|
||
|
|
assert len(emails) == 1
|
||
|
|
|
||
|
|
|
||
|
|
async def test_slots_capped_at_three(session, tenant, emails, sms):
|
||
|
|
req, _ = await create(session, tenant, slots=[slot(1), slot(2), slot(3), slot(4), slot(5)])
|
||
|
|
assert len(req.slots) == 3
|