Files
gogo-telefon/tests/test_partner_api.py
Senad Uka e855650f09 M1: backend core + proposal engine
- FastAPI skeleton, SQLAlchemy models (§13), Alembic initial migration
- SchedulingProvider interface with google_calendar (free/busy read-only),
  partner_api (Appendix B client) and mock implementations
- Proposal engine: create → provider-routed delivery → owner actions
  (resolve/confirm+SMS/reject) → expiry + reminders (§9)
- Signed single-use action links, .ics METHOD:REQUEST attachment
- Partner outcome webhook with HMAC verification + polling fallback
- SmsProvider (console) with Bosnian templates (§5.5), EmailProvider (console/SMTP)
- Fake partner API server in tests/ — Appendix B reference implementation
- 43 tests: slot math, proposal lifecycle, action links, partner contract

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:45:06 +02:00

264 lines
9.0 KiB
Python

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