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:
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())
|
||||
Reference in New Issue
Block a user