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>
This commit is contained in:
263
tests/test_partner_api.py
Normal file
263
tests/test_partner_api.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user