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