Files
gogo-telefon/tests/test_proposal_lifecycle.py

170 lines
6.0 KiB
Python
Raw Permalink Normal View History

"""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