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:
2026-07-11 09:45:06 +02:00
commit e855650f09
48 changed files with 4941 additions and 0 deletions

0
tests/__init__.py Normal file
View File

165
tests/conftest.py Normal file
View 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
View 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
View 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
View 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

View 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
View 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
View 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:0018: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) == "6090 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"