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:
176
tests/fake_partner.py
Normal file
176
tests/fake_partner.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""Fake partner booking-software API — reference implementation of Appendix B.
|
||||
|
||||
Used by the test suite to exercise the `partner_api` provider (auth, idempotency
|
||||
replay, webhook signatures, polling fallback) AND intended to be handed to
|
||||
partner IT teams as a working example of the contract they must implement.
|
||||
|
||||
Run standalone: uvicorn tests.fake_partner:app --port 9100
|
||||
(API key: "test-partner-key"; see PartnerState to preload slots.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Header, Query, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
API_KEY = "test-partner-key"
|
||||
WEBHOOK_SECRET = "test-webhook-secret"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PartnerState:
|
||||
"""In-memory 'database' of the partner's booking software."""
|
||||
|
||||
# service_id -> list of slot dicts {"start", "end", optionally staff}
|
||||
slots: dict[str, list[dict]] = field(default_factory=dict)
|
||||
# idempotency key -> stored response
|
||||
idempotency: dict[str, dict] = field(default_factory=dict)
|
||||
# partner_request_id -> request record (incl. "status")
|
||||
requests: dict[str, dict] = field(default_factory=dict)
|
||||
services: list[dict] = field(default_factory=list)
|
||||
seq: int = 0
|
||||
# availability latency injection for tests (seconds)
|
||||
availability_delay: float = 0.0
|
||||
|
||||
def reset(self) -> None:
|
||||
self.slots.clear()
|
||||
self.idempotency.clear()
|
||||
self.requests.clear()
|
||||
self.services.clear()
|
||||
self.seq = 0
|
||||
self.availability_delay = 0.0
|
||||
|
||||
|
||||
state = PartnerState()
|
||||
app = FastAPI(title="Fake Partner Booking API (Appendix B reference)")
|
||||
|
||||
|
||||
def _err(status: int, code: str, message: str) -> JSONResponse:
|
||||
# §B.0 error shape
|
||||
return JSONResponse({"error": {"code": code, "message": message}}, status_code=status)
|
||||
|
||||
|
||||
def _check_auth(authorization: str | None) -> JSONResponse | None:
|
||||
if authorization != f"Bearer {API_KEY}":
|
||||
return _err(401, "unauthorized", "Missing or invalid API key")
|
||||
return None
|
||||
|
||||
|
||||
@app.get("/availability")
|
||||
async def availability(
|
||||
service_id: str = Query(...),
|
||||
home_visit: str = Query("false"),
|
||||
from_: str = Query(..., alias="from"),
|
||||
to: str = Query(...),
|
||||
authorization: str | None = Header(default=None),
|
||||
):
|
||||
"""§B.1 — ready-to-offer free slots; partner does all the slot math."""
|
||||
if err := _check_auth(authorization):
|
||||
return err
|
||||
if state.availability_delay:
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(state.availability_delay)
|
||||
date_from = datetime.fromisoformat(from_).date() if len(from_) > 10 else from_
|
||||
date_to = datetime.fromisoformat(to).date() if len(to) > 10 else to
|
||||
slots = [
|
||||
s
|
||||
for s in state.slots.get(service_id, [])
|
||||
if str(date_from) <= s["start"][:10] <= str(date_to)
|
||||
]
|
||||
return {"service_id": service_id, "slots": slots[:20]}
|
||||
|
||||
|
||||
@app.post("/booking-requests", status_code=201)
|
||||
async def create_booking_request(
|
||||
request: Request,
|
||||
authorization: str | None = Header(default=None),
|
||||
idempotency_key: str | None = Header(default=None),
|
||||
):
|
||||
"""§B.2 — push a booking request; idempotent per Idempotency-Key."""
|
||||
if err := _check_auth(authorization):
|
||||
return err
|
||||
payload = await request.json()
|
||||
|
||||
key = idempotency_key or payload.get("gogo_request_id", "")
|
||||
if key in state.idempotency:
|
||||
return state.idempotency[key] # replay MUST return the original result
|
||||
|
||||
if payload.get("dry_run"):
|
||||
return {"partner_request_id": None, "status": "dry_run_ok"}
|
||||
|
||||
state.seq += 1
|
||||
partner_id = f"REQ-{state.seq:04d}"
|
||||
state.requests[partner_id] = {**payload, "status": "pending"}
|
||||
response = {"partner_request_id": partner_id, "status": "received"}
|
||||
state.idempotency[key] = response
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/booking-requests/{partner_request_id}")
|
||||
async def booking_request_status(
|
||||
partner_request_id: str, authorization: str | None = Header(default=None)
|
||||
):
|
||||
"""§B.3 polling fallback."""
|
||||
if err := _check_auth(authorization):
|
||||
return err
|
||||
rec = state.requests.get(partner_request_id)
|
||||
if rec is None:
|
||||
return _err(404, "not_found", "Unknown partner_request_id")
|
||||
return {"status": rec["status"], "confirmed_slot": rec.get("confirmed_slot")}
|
||||
|
||||
|
||||
@app.get("/services")
|
||||
async def services(authorization: str | None = Header(default=None)):
|
||||
"""§B.4 optional catalog endpoint."""
|
||||
if err := _check_auth(authorization):
|
||||
return err
|
||||
return {"services": state.services}
|
||||
|
||||
|
||||
# -- helpers for driving the partner side in tests / demos -------------------
|
||||
|
||||
|
||||
def sign_webhook(body: bytes, secret: str = WEBHOOK_SECRET) -> str:
|
||||
return "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
async def staff_resolve(
|
||||
gogo_base: httpx.AsyncClient,
|
||||
tenant_id: str,
|
||||
partner_request_id: str,
|
||||
outcome: str,
|
||||
confirmed_slot: dict | None = None,
|
||||
secret: str = WEBHOOK_SECRET,
|
||||
) -> httpx.Response:
|
||||
"""Simulate salon staff resolving a request in the partner software:
|
||||
updates local state and calls Gogo's outcome webhook (§B.3)."""
|
||||
import json as jsonlib
|
||||
|
||||
rec = state.requests.get(partner_request_id)
|
||||
if rec is not None:
|
||||
rec["status"] = "resolved" if outcome == "resolved" else outcome
|
||||
if confirmed_slot:
|
||||
rec["confirmed_slot"] = confirmed_slot
|
||||
payload = {
|
||||
"gogo_request_id": rec["gogo_request_id"] if rec else None,
|
||||
"partner_request_id": partner_request_id,
|
||||
"outcome": outcome,
|
||||
}
|
||||
if confirmed_slot:
|
||||
payload["confirmed_slot"] = confirmed_slot
|
||||
body = jsonlib.dumps(payload).encode()
|
||||
return await gogo_base.post(
|
||||
f"/webhooks/partner/{tenant_id}",
|
||||
content=body,
|
||||
headers={
|
||||
"X-Gogo-Signature": sign_webhook(body, secret),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user