"""partner_api provider against the fake partner server (Appendix B): availability pull, request push + idempotency, catalog sync, webhook outcomes (signature, idempotent replay), polling fallback.""" import json from sqlalchemy import select from gogo.domain import BookingStatus, Slot from gogo.proposals import engine from gogo.scheduling.base import get_provider from tests.fake_partner import sign_webhook, staff_resolve SLOT_1 = {"start": "2026-07-15T14:30:00+02:00", "end": "2026-07-15T15:15:00+02:00", "staff_id": "EMP-3", "staff_name": "Merima"} SLOT_2 = {"start": "2026-07-15T17:00:00+02:00", "end": "2026-07-15T17:45:00+02:00"} async def create_partner_request(session, partner_tenant, **kw): from gogo.models import Service svc = ( await session.execute(select(Service).where(Service.tenant_id == partner_tenant.id)) ).scalar_one() defaults = dict( service_id=str(svc.id), source="voice", client_name="Amra Hodžić", client_phone="+38765123456", service_name_raw="manikir", slots=[Slot.model_validate(SLOT_2)], summary="Klijentica želi manikir.", ) defaults.update(kw) return await engine.create_booking_request(session, partner_tenant, **defaults) async def test_availability_pull(session, partner_tenant, wire_fake_partner): from datetime import date wire_fake_partner.slots["SRV-12"] = [SLOT_1, SLOT_2] provider = await get_provider(session, partner_tenant) from gogo.models import Service svc = ( await session.execute(select(Service).where(Service.tenant_id == partner_tenant.id)) ).scalar_one() slots = await provider.get_availability(str(svc.id), date(2026, 7, 15), date(2026, 7, 22)) assert len(slots) == 2 assert slots[0].staff_name == "Merima" assert slots[1].staff_name is None async def test_push_and_idempotency(session, partner_tenant, wire_fake_partner, emails, sms): req, result = await create_partner_request(session, partner_tenant) await session.commit() assert result.ok assert result.partner_request_id == "REQ-0001" assert req.partner_request_id == "REQ-0001" # partner tenant default: no owner email (§8.2) assert emails == [] stored = wire_fake_partner.requests["REQ-0001"] assert stored["client"]["name"] == "Amra Hodžić" assert stored["service_id"] == "SRV-12" # mapped to the partner's id assert stored["source"] == "voice" # replaying the same push (same Idempotency-Key) must not create a duplicate provider = await get_provider(session, partner_tenant) from gogo.domain import BookingRequestData from gogo.models import utcnow replay = BookingRequestData( gogo_request_id=str(req.id), tenant_id=str(partner_tenant.id), created_at=utcnow(), source="voice", client_name=req.client_name, client_phone=req.client_phone, summary=req.summary, ) result2 = await provider.deliver_request(replay) assert result2.partner_request_id == "REQ-0001" assert len(wire_fake_partner.requests) == 1 async def test_dry_run_creates_nothing(session, partner_tenant, wire_fake_partner): from gogo.domain import BookingRequestData from gogo.models import utcnow provider = await get_provider(session, partner_tenant) result = await provider.deliver_request( BookingRequestData( gogo_request_id="00000000-0000-0000-0000-000000000001", tenant_id=str(partner_tenant.id), created_at=utcnow(), source="chat", client_name="Test", client_phone="+38700000000", dry_run=True, ) ) assert result.ok assert wire_fake_partner.requests == {} async def test_catalog_sync(session, partner_tenant, wire_fake_partner): wire_fake_partner.services = [ {"id": "SRV-12", "name": "Manikir", "duration_min": 45, "price_min": 20, "price_max": 25, "currency": "BAM", "home_visit": False, "active": True}, {"id": "SRV-13", "name": "Gel nokti", "duration_min": 90, "price_min": 50, "price_max": 70, "currency": "BAM", "home_visit": False, "active": True}, ] provider = await get_provider(session, partner_tenant) services = await provider.get_services() assert [s.name for s in services] == ["Manikir", "Gel nokti"] assert services[1].duration_min == 90 async def test_webhook_confirmed_sends_sms( session, partner_tenant, wire_fake_partner, gogo_client, sms ): req, _ = await create_partner_request(session, partner_tenant) await session.commit() resp = await staff_resolve( gogo_client, str(partner_tenant.id), "REQ-0001", "confirmed", confirmed_slot=SLOT_2 ) assert resp.status_code == 200 await session.refresh(req) assert req.status == BookingStatus.confirmed.value assert len(sms) == 1 assert "Potvrđen termin" in sms[0][1] # idempotent replay: same outcome again → ok, no second SMS resp = await staff_resolve( gogo_client, str(partner_tenant.id), "REQ-0001", "confirmed", confirmed_slot=SLOT_2 ) assert resp.status_code == 200 assert len(sms) == 1 async def test_webhook_resolved_sends_nothing( session, partner_tenant, wire_fake_partner, gogo_client, sms ): req, _ = await create_partner_request(session, partner_tenant) await session.commit() resp = await staff_resolve(gogo_client, str(partner_tenant.id), "REQ-0001", "resolved") assert resp.status_code == 200 await session.refresh(req) assert req.status == BookingStatus.resolved_by_owner.value assert sms == [] async def test_webhook_rejected_sends_rejection_sms( session, partner_tenant, wire_fake_partner, gogo_client, sms ): req, _ = await create_partner_request(session, partner_tenant) await session.commit() resp = await staff_resolve(gogo_client, str(partner_tenant.id), "REQ-0001", "rejected") assert resp.status_code == 200 await session.refresh(req) assert req.status == BookingStatus.rejected.value assert len(sms) == 1 assert "nažalost" in sms[0][1].lower() async def test_webhook_bad_signature_rejected( session, partner_tenant, wire_fake_partner, gogo_client, sms ): req, _ = await create_partner_request(session, partner_tenant) await session.commit() body = json.dumps( {"gogo_request_id": str(req.id), "partner_request_id": "REQ-0001", "outcome": "confirmed", "confirmed_slot": SLOT_2} ).encode() resp = await gogo_client.post( f"/webhooks/partner/{partner_tenant.id}", content=body, headers={ "X-Gogo-Signature": sign_webhook(body, secret="wrong-secret"), "Content-Type": "application/json", }, ) assert resp.status_code == 401 await session.refresh(req) assert req.status == BookingStatus.pending.value assert sms == [] # missing header entirely resp = await gogo_client.post( f"/webhooks/partner/{partner_tenant.id}", content=body, headers={"Content-Type": "application/json"}, ) assert resp.status_code == 401 async def test_webhook_confirmed_requires_slot( session, partner_tenant, wire_fake_partner, gogo_client ): req, _ = await create_partner_request(session, partner_tenant) await session.commit() body = json.dumps( {"gogo_request_id": str(req.id), "partner_request_id": "REQ-0001", "outcome": "confirmed"} ).encode() resp = await gogo_client.post( f"/webhooks/partner/{partner_tenant.id}", content=body, headers={"X-Gogo-Signature": sign_webhook(body), "Content-Type": "application/json"}, ) assert resp.status_code == 400 assert resp.json()["error"]["code"] == "missing_slot" async def test_webhook_conflicting_outcome_409( session, partner_tenant, wire_fake_partner, gogo_client, sms ): req, _ = await create_partner_request(session, partner_tenant) await session.commit() resp = await staff_resolve(gogo_client, str(partner_tenant.id), "REQ-0001", "resolved") assert resp.status_code == 200 resp = await staff_resolve( gogo_client, str(partner_tenant.id), "REQ-0001", "confirmed", confirmed_slot=SLOT_2 ) assert resp.status_code == 409 async def test_polling_fallback(session, partner_tenant, wire_fake_partner, sms, monkeypatch): from gogo import jobs from gogo.models import ProviderConfig # enable polling for the tenant cfg = ( await session.execute( select(ProviderConfig).where(ProviderConfig.tenant_id == partner_tenant.id) ) ).scalar_one() cfg.config = {**cfg.config, "polling_fallback": True} req, _ = await create_partner_request(session, partner_tenant) await session.commit() # staff confirm inside the partner software only (no webhook call) wire_fake_partner.requests["REQ-0001"]["status"] = "confirmed" wire_fake_partner.requests["REQ-0001"]["confirmed_slot"] = SLOT_2 await jobs.run_partner_polling_job() await session.refresh(req) assert req.status == BookingStatus.confirmed.value assert len(sms) == 1