- 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>
264 lines
9.0 KiB
Python
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
|