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:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
165
tests/conftest.py
Normal file
165
tests/conftest.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""Shared fixtures: file-backed SQLite DB per test, console email/SMS capture,
|
||||
seeded tenant, fake partner wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
os.environ.setdefault("GOGO_ENV", "test")
|
||||
os.environ.setdefault("GOGO_SECRET_KEY", "test-secret-key-for-tests-only")
|
||||
os.environ.setdefault("GOGO_BASE_URL", "http://testserver")
|
||||
|
||||
import httpx # noqa: E402
|
||||
import pytest # noqa: E402
|
||||
|
||||
import gogo.db as db # noqa: E402
|
||||
from gogo.config import get_settings # noqa: E402
|
||||
from gogo.crypto import encrypt # noqa: E402
|
||||
from gogo.email.sender import ConsoleEmailProvider, set_email_provider # noqa: E402
|
||||
from gogo.hours import DEFAULT_WORKING_HOURS # noqa: E402
|
||||
from gogo.models import ProviderConfig, Service, Tenant # noqa: E402
|
||||
from gogo.scheduling.mock import MockProvider # noqa: E402
|
||||
from gogo.scheduling.partner_api import PartnerApiProvider # noqa: E402
|
||||
from gogo.sms.base import ConsoleSmsProvider, set_sms_provider # noqa: E402
|
||||
from tests import fake_partner # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session(tmp_path):
|
||||
"""Fresh file-backed SQLite DB; the app's get_session sees the same file."""
|
||||
db_path = tmp_path / "test.db"
|
||||
os.environ["GOGO_DATABASE_URL"] = f"sqlite+aiosqlite:///{db_path}"
|
||||
get_settings.cache_clear()
|
||||
db.reset_engine()
|
||||
engine = db.get_engine()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(db.Base.metadata.create_all)
|
||||
async with db.get_sessionmaker()() as s:
|
||||
yield s
|
||||
await engine.dispose()
|
||||
db.reset_engine()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def emails():
|
||||
provider = ConsoleEmailProvider()
|
||||
set_email_provider(provider)
|
||||
yield provider.sent
|
||||
set_email_provider(None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sms():
|
||||
provider = ConsoleSmsProvider()
|
||||
set_sms_provider(provider)
|
||||
yield provider.sent
|
||||
set_sms_provider(None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_mock_provider():
|
||||
MockProvider.busy_blocks = {}
|
||||
MockProvider.now_override = None
|
||||
yield MockProvider
|
||||
MockProvider.busy_blocks = {}
|
||||
MockProvider.now_override = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def tenant(session) -> Tenant:
|
||||
"""Seeded mock-provider tenant 'Salon Merima' with two services."""
|
||||
t = Tenant(
|
||||
name="Salon Merima",
|
||||
slug="salon-merima",
|
||||
city="Banja Luka",
|
||||
working_hours=DEFAULT_WORKING_HOURS,
|
||||
scheduling_provider="mock",
|
||||
notify_emails=["merima@example.ba"],
|
||||
)
|
||||
session.add(t)
|
||||
await session.flush()
|
||||
session.add_all(
|
||||
[
|
||||
Service(
|
||||
tenant_id=t.id, name="Šišanje i feniranje", duration_min=45,
|
||||
price_min=25, price_max=35,
|
||||
),
|
||||
Service(
|
||||
tenant_id=t.id, name="Pedikir", duration_min=60,
|
||||
price_min=30, price_max=30, home_visit=True,
|
||||
),
|
||||
]
|
||||
)
|
||||
await session.commit()
|
||||
return t
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def partner_tenant(session) -> Tenant:
|
||||
"""Tenant wired to the fake partner API via ASGI transport."""
|
||||
t = Tenant(
|
||||
name="Salon Aida",
|
||||
slug="salon-aida",
|
||||
city="Sarajevo",
|
||||
working_hours=DEFAULT_WORKING_HOURS,
|
||||
scheduling_provider="partner_api",
|
||||
notify_emails=[],
|
||||
)
|
||||
session.add(t)
|
||||
await session.flush()
|
||||
session.add(
|
||||
ProviderConfig(
|
||||
tenant_id=t.id,
|
||||
provider_type="partner_api",
|
||||
config={
|
||||
"base_url": "http://partner.test",
|
||||
"api_key_encrypted": encrypt(fake_partner.API_KEY),
|
||||
"webhook_secret_encrypted": encrypt(fake_partner.WEBHOOK_SECRET),
|
||||
"catalog_sync": True,
|
||||
"email_to_owner": False,
|
||||
"polling_fallback": False,
|
||||
},
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
Service(
|
||||
tenant_id=t.id, name="Manikir", duration_min=45,
|
||||
price_min=20, price_max=25, partner_service_id="SRV-12",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return t
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wire_fake_partner(monkeypatch):
|
||||
"""Point PartnerApiProvider's HTTP client at the fake partner ASGI app."""
|
||||
fake_partner.state.reset()
|
||||
|
||||
def factory(timeout):
|
||||
return httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=fake_partner.app),
|
||||
base_url="http://partner.test",
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(PartnerApiProvider, "client_factory", staticmethod(factory))
|
||||
yield fake_partner.state
|
||||
fake_partner.state.reset()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def gogo_client(session):
|
||||
"""HTTP client against the Gogo app (shares the test DB via GOGO_DATABASE_URL)."""
|
||||
from gogo.main import create_app
|
||||
|
||||
app = create_app()
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
|
||||
def make_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
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",
|
||||
},
|
||||
)
|
||||
133
tests/test_action_links.py
Normal file
133
tests/test_action_links.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Owner email action links over HTTP (§9.1-9.2, idempotency per §15)."""
|
||||
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from gogo.domain import BookingStatus
|
||||
from tests.test_proposal_lifecycle import create
|
||||
|
||||
|
||||
def extract_links(text: str) -> dict[str, str]:
|
||||
"""Pull action URLs out of the plain-text proposal email."""
|
||||
links = {}
|
||||
resolve = re.search(r"RIJEŠENO[^\n]*\n\s+(http\S+)", text)
|
||||
confirm = re.search(r"POTVRDI[^\n]*\n\s+(http\S+)", text)
|
||||
reject = re.search(r"ODBIJ[^\n]*\n\s+(http\S+)", text)
|
||||
if resolve:
|
||||
links["resolve"] = resolve.group(1)
|
||||
if confirm:
|
||||
links["confirm"] = confirm.group(1)
|
||||
if reject:
|
||||
links["reject"] = reject.group(1)
|
||||
return links
|
||||
|
||||
|
||||
async def test_resolve_link_closes_request_no_sms(session, tenant, emails, sms, gogo_client):
|
||||
req, _ = await create(session, tenant)
|
||||
await session.commit()
|
||||
links = extract_links(emails[0].text)
|
||||
|
||||
resp = await gogo_client.get(links["resolve"])
|
||||
assert resp.status_code == 200
|
||||
assert "Označeno kao riješeno" in resp.text
|
||||
|
||||
await session.refresh(req)
|
||||
assert req.status == BookingStatus.resolved_by_owner.value
|
||||
assert sms == []
|
||||
|
||||
# idempotent: second click shows friendly "already handled" page
|
||||
resp2 = await gogo_client.get(links["resolve"])
|
||||
assert resp2.status_code == 200
|
||||
assert "već obrađen" in resp2.text
|
||||
|
||||
|
||||
async def test_confirm_link_sends_sms(session, tenant, emails, sms, gogo_client):
|
||||
req, _ = await create(session, tenant)
|
||||
await session.commit()
|
||||
links = extract_links(emails[0].text)
|
||||
|
||||
resp = await gogo_client.get(links["confirm"])
|
||||
assert resp.status_code == 200
|
||||
assert "Termin potvrđen" in resp.text
|
||||
|
||||
await session.refresh(req)
|
||||
assert req.status == BookingStatus.confirmed.value
|
||||
assert len(sms) == 1
|
||||
assert "Potvrđen termin" in sms[0][1]
|
||||
|
||||
|
||||
async def test_reject_link_shows_editable_form_then_sends(
|
||||
session, tenant, emails, sms, gogo_client
|
||||
):
|
||||
req, _ = await create(session, tenant)
|
||||
await session.commit()
|
||||
links = extract_links(emails[0].text)
|
||||
|
||||
# GET shows the form with the default template prefilled — nothing sent yet
|
||||
resp = await gogo_client.get(links["reject"])
|
||||
assert resp.status_code == 200
|
||||
assert "<textarea" in resp.text
|
||||
assert sms == []
|
||||
|
||||
resp = await gogo_client.post(
|
||||
links["reject"] + "/reject", data={"sms_body": "Nažalost, popunjeni smo. Salon Merima"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
await session.refresh(req)
|
||||
assert req.status == BookingStatus.rejected.value
|
||||
assert sms == [("+38765123456", "Nažalost, popunjeni smo. Salon Merima")]
|
||||
|
||||
|
||||
async def test_forged_token_rejected(gogo_client, session, tenant):
|
||||
resp = await gogo_client.get("/a/forged-token-value")
|
||||
assert resp.status_code == 400
|
||||
assert "Nevažeći link" in resp.text
|
||||
|
||||
|
||||
async def test_action_after_expiry_shows_status(session, tenant, emails, sms, gogo_client):
|
||||
from gogo.proposals import engine as pe
|
||||
|
||||
req, _ = await create(session, tenant)
|
||||
req.expires_at = datetime.now(UTC) - timedelta(minutes=1)
|
||||
await pe.process_expirations(session)
|
||||
await session.commit()
|
||||
sms.clear()
|
||||
|
||||
links = extract_links(emails[0].text)
|
||||
resp = await gogo_client.get(links["confirm"])
|
||||
assert resp.status_code == 200
|
||||
assert "već obrađen" in resp.text
|
||||
assert "istekao" in resp.text
|
||||
assert sms == []
|
||||
|
||||
|
||||
async def test_confirm_link_warns_when_slot_taken(session, tenant, emails, sms, gogo_client):
|
||||
"""Recheck finds slot busy → warning page with force-confirm, no SMS yet."""
|
||||
from gogo.scheduling.mock import MockProvider
|
||||
|
||||
req, _ = await create(session, tenant)
|
||||
await session.commit()
|
||||
links = extract_links(emails[0].text)
|
||||
|
||||
async def is_slot_free(self, service_id, slot_):
|
||||
return False
|
||||
|
||||
MockProvider.is_slot_free = is_slot_free
|
||||
try:
|
||||
resp = await gogo_client.get(links["confirm"])
|
||||
assert resp.status_code == 200
|
||||
assert "u međuvremenu zauzet" in resp.text
|
||||
await session.refresh(req)
|
||||
assert req.status == BookingStatus.pending.value
|
||||
assert sms == []
|
||||
|
||||
# owner forces the confirmation anyway
|
||||
resp = await gogo_client.post(links["confirm"] + "/force-confirm")
|
||||
assert resp.status_code == 200
|
||||
assert "Termin potvrđen" in resp.text
|
||||
finally:
|
||||
del MockProvider.is_slot_free
|
||||
|
||||
await session.refresh(req)
|
||||
assert req.status == BookingStatus.confirmed.value
|
||||
assert len(sms) == 1
|
||||
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
|
||||
169
tests/test_proposal_lifecycle.py
Normal file
169
tests/test_proposal_lifecycle.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""Proposal engine end-to-end against the mock provider (§9, M1)."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from gogo.domain import BookingStatus, Slot
|
||||
from gogo.models import ActionToken
|
||||
from gogo.proposals import engine
|
||||
|
||||
TZ = ZoneInfo("Europe/Sarajevo")
|
||||
|
||||
|
||||
def slot(days_ahead: int = 2, hour: int = 17) -> Slot:
|
||||
base = datetime.now(TZ).replace(hour=hour, minute=0, second=0, microsecond=0)
|
||||
start = base + timedelta(days=days_ahead)
|
||||
return Slot(start=start, end=start + timedelta(minutes=45))
|
||||
|
||||
|
||||
async def create(session, tenant, **kw):
|
||||
defaults = dict(
|
||||
source="voice",
|
||||
client_name="Amra Hodžić",
|
||||
client_phone="+38765123456",
|
||||
service_name_raw="šišanje i feniranje",
|
||||
slots=[slot()],
|
||||
summary="Klijentica želi šišanje i feniranje.",
|
||||
)
|
||||
defaults.update(kw)
|
||||
return await engine.create_booking_request(session, tenant, **defaults)
|
||||
|
||||
|
||||
async def test_create_sends_email_with_actions_and_ics(session, tenant, emails, sms):
|
||||
req, result = await create(session, tenant)
|
||||
await session.commit()
|
||||
|
||||
assert result.ok
|
||||
assert req.status == BookingStatus.pending.value
|
||||
assert req.expires_at is not None
|
||||
|
||||
assert len(emails) == 1
|
||||
mail = emails[0]
|
||||
assert mail.to == ["merima@example.ba"]
|
||||
assert "Novi zahtjev za termin" in mail.subject
|
||||
assert "Amra Hodžić" in mail.subject
|
||||
assert "+38765123456" in mail.text
|
||||
assert "RIJEŠENO" in mail.text
|
||||
assert "POTVRDI" in mail.text
|
||||
assert "ODBIJ" in mail.text
|
||||
# .ics attachment with METHOD:REQUEST
|
||||
assert mail.attachments and mail.attachments[0][0] == "termin.ics"
|
||||
assert b"METHOD:REQUEST" in mail.attachments[0][2]
|
||||
assert b"BEGIN:VEVENT" in mail.attachments[0][2]
|
||||
|
||||
# action tokens persisted: resolve + confirm(1 slot) + reject
|
||||
tokens = (await session.execute(select(ActionToken))).scalars().all()
|
||||
assert {t.action for t in tokens} == {"resolve", "confirm", "reject"}
|
||||
|
||||
# no client SMS by default (sms_request_received off)
|
||||
assert sms == []
|
||||
|
||||
|
||||
async def test_request_received_sms_when_enabled(session, tenant, emails, sms):
|
||||
tenant.sms_request_received = True
|
||||
await create(session, tenant)
|
||||
await session.commit()
|
||||
assert len(sms) == 1
|
||||
assert sms[0][0] == "+38765123456"
|
||||
assert "Primili smo vaš zahtjev" in sms[0][1]
|
||||
|
||||
|
||||
async def test_resolve_sends_no_sms(session, tenant, emails, sms):
|
||||
req, _ = await create(session, tenant)
|
||||
await engine.resolve_request(session, tenant, req)
|
||||
await session.commit()
|
||||
assert req.status == BookingStatus.resolved_by_owner.value
|
||||
assert req.resolved_at is not None
|
||||
assert sms == [] # primary flow: owner contacted client directly, Gogo sends nothing
|
||||
|
||||
|
||||
async def test_confirm_sends_confirmation_sms(session, tenant, emails, sms):
|
||||
s = slot()
|
||||
req, _ = await create(session, tenant, slots=[s])
|
||||
ok = await engine.confirm_request(session, tenant, req, s)
|
||||
await session.commit()
|
||||
assert ok
|
||||
assert req.status == BookingStatus.confirmed.value
|
||||
assert len(sms) == 1
|
||||
to, body = sms[0]
|
||||
assert to == "+38765123456"
|
||||
assert "Potvrđen termin" in body
|
||||
assert "Salon Merima" in body
|
||||
|
||||
|
||||
async def test_confirm_recheck_busy_blocks_transition(
|
||||
session, tenant, emails, sms, clean_mock_provider
|
||||
):
|
||||
s = slot()
|
||||
req, _ = await create(session, tenant, slots=[s])
|
||||
# is_slot_free is only defined for providers that compute availability;
|
||||
# mock has no is_slot_free → recheck is skipped. Simulate a gcal-style
|
||||
# provider recheck by attaching one.
|
||||
from gogo.scheduling.mock import MockProvider
|
||||
|
||||
async def is_slot_free(self, service_id, slot_):
|
||||
return False
|
||||
|
||||
MockProvider.is_slot_free = is_slot_free
|
||||
try:
|
||||
ok = await engine.confirm_request(session, tenant, req, s, recheck=True)
|
||||
finally:
|
||||
del MockProvider.is_slot_free
|
||||
assert not ok
|
||||
assert req.status == BookingStatus.pending.value
|
||||
assert sms == []
|
||||
|
||||
|
||||
async def test_reject_sends_sms_with_custom_text(session, tenant, emails, sms):
|
||||
req, _ = await create(session, tenant)
|
||||
await engine.reject_request(session, tenant, req, custom_sms="Nažalost ne može ovaj termin.")
|
||||
await session.commit()
|
||||
assert req.status == BookingStatus.rejected.value
|
||||
assert sms == [("+38765123456", "Nažalost ne može ovaj termin.")]
|
||||
|
||||
|
||||
async def test_transitions_are_exclusive(session, tenant, emails, sms):
|
||||
req, _ = await create(session, tenant)
|
||||
await engine.resolve_request(session, tenant, req)
|
||||
with pytest.raises(engine.TransitionError):
|
||||
await engine.reject_request(session, tenant, req)
|
||||
# idempotent replay of same action is fine
|
||||
await engine.resolve_request(session, tenant, req)
|
||||
assert req.status == BookingStatus.resolved_by_owner.value
|
||||
|
||||
|
||||
async def test_expiry_job_expires_and_sends_apology(session, tenant, emails, sms):
|
||||
req, _ = await create(session, tenant)
|
||||
req.expires_at = datetime.now(UTC) - timedelta(minutes=1)
|
||||
await session.flush()
|
||||
expired = await engine.process_expirations(session)
|
||||
await session.commit()
|
||||
assert expired == 1
|
||||
assert req.status == BookingStatus.expired.value
|
||||
assert len(sms) == 1
|
||||
assert "pozovite nas ponovo" in sms[0][1].lower() or "pozovite" in sms[0][1].lower()
|
||||
|
||||
|
||||
async def test_reminder_email_at_half_ttl(session, tenant, emails, sms):
|
||||
req, _ = await create(session, tenant)
|
||||
emails.clear()
|
||||
# 24h TTL → reminder when <= 12h remain
|
||||
req.expires_at = datetime.now(UTC) + timedelta(hours=11)
|
||||
await session.flush()
|
||||
expired = await engine.process_expirations(session)
|
||||
await session.commit()
|
||||
assert expired == 0
|
||||
assert req.reminder_sent
|
||||
assert len(emails) == 1
|
||||
assert "Podsjetnik" in emails[0].subject
|
||||
# second run must not re-send
|
||||
await engine.process_expirations(session)
|
||||
assert len(emails) == 1
|
||||
|
||||
|
||||
async def test_slots_capped_at_three(session, tenant, emails, sms):
|
||||
req, _ = await create(session, tenant, slots=[slot(1), slot(2), slot(3), slot(4), slot(5)])
|
||||
assert len(req.slots) == 3
|
||||
96
tests/test_slots.py
Normal file
96
tests/test_slots.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Slot computation (§8.1): working hours − busy, quantization, notice, horizon."""
|
||||
|
||||
from datetime import date, datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from gogo.scheduling.slots import compute_slots, subtract_busy
|
||||
|
||||
TZ = ZoneInfo("Europe/Sarajevo")
|
||||
|
||||
|
||||
def dt(day: int, h: int, m: int = 0) -> datetime:
|
||||
return datetime(2026, 7, day, h, m, tzinfo=TZ)
|
||||
|
||||
|
||||
WH = {
|
||||
"mon": [["09:00", "13:00"], ["14:00", "18:00"]], # lunch break
|
||||
"tue": [["09:00", "18:00"]],
|
||||
"wed": [["09:00", "18:00"]],
|
||||
"thu": [["09:00", "18:00"]],
|
||||
"fri": [["09:00", "18:00"]],
|
||||
"sat": [["09:00", "14:00"]],
|
||||
"sun": [],
|
||||
}
|
||||
|
||||
# Mon 2026-07-13 .. Sun 2026-07-19
|
||||
NOW = dt(13, 8, 0) # Monday 08:00
|
||||
|
||||
|
||||
def test_subtract_busy_splits_interval():
|
||||
free = subtract_busy((dt(13, 9), dt(13, 13)), [(dt(13, 10), dt(13, 11))])
|
||||
assert free == [(dt(13, 9), dt(13, 10)), (dt(13, 11), dt(13, 13))]
|
||||
|
||||
|
||||
def test_subtract_busy_no_overlap():
|
||||
free = subtract_busy((dt(13, 9), dt(13, 13)), [(dt(13, 14), dt(13, 15))])
|
||||
assert free == [(dt(13, 9), dt(13, 13))]
|
||||
|
||||
|
||||
def test_slots_respect_break_and_duration():
|
||||
slots = compute_slots(
|
||||
working_hours=WH, busy=[], duration_min=60, date_from=date(2026, 7, 13),
|
||||
date_to=date(2026, 7, 13), tz=TZ, now=NOW, min_notice_hours=0, max_slots=50,
|
||||
)
|
||||
starts = [s.start for s in slots]
|
||||
# no slot may span the 13:00-14:00 break
|
||||
assert dt(13, 13) not in starts
|
||||
for s in slots:
|
||||
assert not (s.start < dt(13, 14) and s.end > dt(13, 13))
|
||||
assert slots[0].start == dt(13, 9)
|
||||
assert all((s.end - s.start).total_seconds() == 3600 for s in slots)
|
||||
|
||||
|
||||
def test_min_notice_pushes_first_slot():
|
||||
slots = compute_slots(
|
||||
working_hours=WH, busy=[], duration_min=30, date_from=date(2026, 7, 13),
|
||||
date_to=date(2026, 7, 13), tz=TZ, now=dt(13, 9, 5), min_notice_hours=2,
|
||||
)
|
||||
assert slots[0].start >= dt(13, 11, 5)
|
||||
|
||||
|
||||
def test_busy_blocks_remove_slots():
|
||||
slots = compute_slots(
|
||||
working_hours=WH, busy=[(dt(14, 9), dt(14, 12))], duration_min=60,
|
||||
date_from=date(2026, 7, 14), date_to=date(2026, 7, 14), tz=TZ, now=NOW,
|
||||
min_notice_hours=0,
|
||||
)
|
||||
assert slots[0].start == dt(14, 12)
|
||||
|
||||
|
||||
def test_sunday_closed():
|
||||
slots = compute_slots(
|
||||
working_hours=WH, busy=[], duration_min=30, date_from=date(2026, 7, 19),
|
||||
date_to=date(2026, 7, 19), tz=TZ, now=NOW, min_notice_hours=0,
|
||||
)
|
||||
assert slots == []
|
||||
|
||||
|
||||
def test_max_days_ahead_horizon():
|
||||
slots = compute_slots(
|
||||
working_hours=WH, busy=[], duration_min=30, date_from=date(2026, 7, 13),
|
||||
date_to=date(2026, 8, 30), tz=TZ, now=NOW, min_notice_hours=0,
|
||||
max_days_ahead=2, max_slots=1000,
|
||||
)
|
||||
assert max(s.start for s in slots).date() <= date(2026, 7, 15)
|
||||
|
||||
|
||||
def test_buffer_extends_step():
|
||||
slots = compute_slots(
|
||||
working_hours=WH, busy=[], duration_min=45, buffer_min=15,
|
||||
date_from=date(2026, 7, 14), date_to=date(2026, 7, 14), tz=TZ, now=NOW,
|
||||
min_notice_hours=0,
|
||||
)
|
||||
# steps of 60 (45+15), but slot end = start + 45
|
||||
assert slots[0].start == dt(14, 9)
|
||||
assert slots[0].end == dt(14, 9, 45)
|
||||
assert slots[1].start == dt(14, 10)
|
||||
67
tests/test_units.py
Normal file
67
tests/test_units.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Small unit tests: working hours, Bosnian formatting, SMS templates, crypto."""
|
||||
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from gogo import crypto
|
||||
from gogo.hours import DEFAULT_WORKING_HOURS, hours_summary_bs, is_open_at
|
||||
from gogo.i18n import fmt_price, fmt_slot
|
||||
from gogo.models import Tenant
|
||||
from gogo.sms.templates import render_sms
|
||||
|
||||
TZ = ZoneInfo("Europe/Sarajevo")
|
||||
|
||||
|
||||
def test_is_open_at():
|
||||
wh = DEFAULT_WORKING_HOURS
|
||||
assert is_open_at(wh, datetime(2026, 7, 15, 10, 0, tzinfo=TZ), TZ) # Wed 10:00
|
||||
assert not is_open_at(wh, datetime(2026, 7, 15, 20, 0, tzinfo=TZ), TZ) # Wed 20:00
|
||||
assert not is_open_at(wh, datetime(2026, 7, 19, 10, 0, tzinfo=TZ), TZ) # Sun
|
||||
assert is_open_at(wh, datetime(2026, 7, 18, 13, 59, tzinfo=TZ), TZ) # Sat 13:59
|
||||
assert not is_open_at(wh, datetime(2026, 7, 18, 14, 0, tzinfo=TZ), TZ) # Sat 14:00
|
||||
|
||||
|
||||
def test_hours_summary_bs():
|
||||
s = hours_summary_bs(DEFAULT_WORKING_HOURS)
|
||||
assert "ponedjeljak: 09:00–18:00" in s
|
||||
assert "nedjelja: zatvoreno" in s
|
||||
|
||||
|
||||
def test_fmt_slot_bosnian():
|
||||
dt = datetime(2026, 7, 15, 17, 0, tzinfo=TZ) # Wednesday
|
||||
assert fmt_slot(dt) == "srijeda, 15.07. u 17:00"
|
||||
|
||||
|
||||
def test_fmt_price():
|
||||
assert fmt_price(60, 90) == "60–90 KM"
|
||||
assert fmt_price(25, 25) == "25 KM"
|
||||
assert fmt_price(30, None) == "30 KM"
|
||||
assert fmt_price(None, None) == "cijena na upit"
|
||||
|
||||
|
||||
def make_tenant(**kw):
|
||||
defaults = dict(name="Salon Merima", sms_templates={})
|
||||
defaults.update(kw)
|
||||
return Tenant(**defaults)
|
||||
|
||||
|
||||
def test_sms_default_templates():
|
||||
t = make_tenant()
|
||||
body = render_sms(
|
||||
t, "confirmation", usluga="Manikir", dan="srijeda", datum="15.07.2026.", vrijeme="17:00"
|
||||
)
|
||||
assert body == "Potvrđen termin: Manikir, srijeda 15.07.2026. u 17:00h — Salon Merima."
|
||||
|
||||
|
||||
def test_sms_tenant_override():
|
||||
t = make_tenant(sms_templates={"rejection": "{salon}: ne može, žao nam je."})
|
||||
assert render_sms(t, "rejection") == "Salon Merima: ne može, žao nam je."
|
||||
|
||||
|
||||
def test_sms_unknown_placeholder_left_intact():
|
||||
t = make_tenant(sms_templates={"missed_call": "Pozovite {salon} — {nepoznato}"})
|
||||
assert render_sms(t, "missed_call", link="x") == "Pozovite Salon Merima — {nepoznato}"
|
||||
|
||||
|
||||
def test_crypto_roundtrip():
|
||||
assert crypto.decrypt(crypto.encrypt("tajna-lozinka")) == "tajna-lozinka"
|
||||
Reference in New Issue
Block a user