- 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>
166 lines
4.8 KiB
Python
166 lines
4.8 KiB
Python
"""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())
|