DealDocumentScreening/tests/unit/test_worker_billing.py

278 lines
9.2 KiB
Python

"""Unit tests for the billing scheduler (ticket 016).
Drive scheduler.run_tick with an injected clock and a fake provider; assert
state transitions without touching the network.
"""
from __future__ import annotations
import datetime as dt
import uuid
from dataclasses import dataclass
from typing import Any
import pytest
from sqlalchemy import text
from contract_check.core.billing.port import PaymentCreated, PaymentProvider, PaymentStatusInfo
from contract_check.core.config import get_settings
from contract_check.core.db.session import create_session_factory
from contract_check.worker_billing.scheduler import run_tick
pytestmark = [pytest.mark.unit, pytest.mark.integration]
@dataclass
class FakeProvider(PaymentProvider):
payments: dict[str, dict[str, Any]]
created: list[PaymentCreated] = None # type: ignore[assignment]
status_overrides: dict[str, str] | None = None
def __post_init__(self) -> None:
self.created = []
self.status_overrides = {}
async def aclose(self) -> None:
pass
async def create_payment(
self,
*,
amount_kopecks: int,
description: str,
return_url: str,
metadata: dict[str, str],
idempotency_key: str,
) -> PaymentCreated:
payment_id = f"pay-{metadata['invoice_id']}"
self.payments[payment_id] = {
"amount_kopecks": amount_kopecks,
"status": "pending",
"metadata": metadata,
}
created = PaymentCreated(payment_id=payment_id, confirmation_url="https://test/pay")
self.created.append(created)
return created
async def get_payment(self, payment_id: str) -> PaymentStatusInfo:
data = self.payments.get(payment_id, {})
status = self.status_overrides.get(payment_id, data.get("status", "pending"))
return PaymentStatusInfo(
payment_id=payment_id,
status=status,
paid=status == "succeeded",
amount_kopecks=data.get("amount_kopecks", 0),
metadata={str(k): str(v) for k, v in data.get("metadata", {}).items()},
)
async def refund(self, *, payment_id: str, amount_kopecks: int, idempotency_key: str) -> Any:
raise NotImplementedError
@pytest.fixture
async def factory():
import os
os.environ["PLANS_ENABLED"] = "true"
os.environ["YOOKASSA_ENABLED"] = "true"
os.environ["YOOKASSA_SHOP_ID"] = "123456"
os.environ["YOOKASSA_SECRET_KEY"] = "test-secret"
os.environ["BILLING_RETURN_JWT_SECRET"] = "pay-secret"
get_settings.cache_clear()
f = create_session_factory()
yield f
os.environ.pop("PLANS_ENABLED", None)
os.environ.pop("YOOKASSA_ENABLED", None)
os.environ.pop("YOOKASSA_SHOP_ID", None)
os.environ.pop("YOOKASSA_SECRET_KEY", None)
os.environ.pop("BILLING_RETURN_JWT_SECRET", None)
get_settings.cache_clear()
async def _seed_user(session, telegram_id: int = 123456789) -> uuid.UUID:
result = await session.execute(
text("SELECT id FROM users WHERE telegram_id = :t"),
{"t": telegram_id},
)
row = result.first()
if row is not None:
return row[0]
result = await session.execute(
text(
"INSERT INTO users (id, telegram_id, credits_left) "
"VALUES (gen_random_uuid(), :t, 0) RETURNING id"
),
{"t": telegram_id},
)
return result.scalar_one()
async def _seed_subscription(
session,
user_id: uuid.UUID,
*,
auto_renew: bool = True,
period_end_offset_days: int = 2,
plan_code: str = "pro",
) -> uuid.UUID:
now = dt.datetime.now(tz=dt.UTC)
# Clean any existing active/past_due subscription for the user to keep the partial unique index happy.
await session.execute(
text(
"UPDATE subscriptions SET status = 'expired' WHERE user_id = :u AND status IN ('active','past_due')"
),
{"u": user_id},
)
result = await session.execute(
text(
"INSERT INTO subscriptions "
"(id, user_id, plan_code, status, current_period_start, current_period_end, auto_renew) "
"VALUES (gen_random_uuid(), :u, :plan, 'active', :start, :end, :renew) "
"RETURNING id"
),
{
"u": user_id,
"plan": plan_code,
"start": now,
"end": now + dt.timedelta(days=period_end_offset_days),
"renew": auto_renew,
},
)
return result.scalar_one()
async def test_scheduler_creates_renewal_invoice(factory) -> None:
provider = FakeProvider(payments={})
async with factory() as session:
user_id = await _seed_user(session)
sub_id = await _seed_subscription(
session, user_id, auto_renew=True, period_end_offset_days=2
)
await session.commit()
now = dt.datetime.now(tz=dt.UTC)
settings = get_settings()
await run_tick(session, provider, now, settings)
invoice = (
await session.execute(
text(
"SELECT kind, subscription_id, external_id, status "
"FROM invoices WHERE subscription_id = :s"
),
{"s": sub_id},
)
).first()
assert invoice is not None
assert invoice[0] == "renewal"
assert invoice[1] == sub_id
assert invoice[2].startswith("pay-")
assert invoice[3] == "pending"
async def test_scheduler_skips_non_autorenew(factory) -> None:
provider = FakeProvider(payments={})
async with factory() as session:
user_id = await _seed_user(session)
sub_id = await _seed_subscription(
session, user_id, auto_renew=False, period_end_offset_days=2
)
await session.commit()
now = dt.datetime.now(tz=dt.UTC)
settings = get_settings()
await run_tick(session, provider, now, settings)
count = (
await session.execute(
text("SELECT count(*) FROM invoices WHERE subscription_id = :s"),
{"s": sub_id},
)
).scalar_one()
assert int(count) == 0
async def test_scheduler_expires_without_renewal(factory) -> None:
async with factory() as session:
user_id = await _seed_user(session)
sub_id = await _seed_subscription(
session, user_id, auto_renew=False, period_end_offset_days=-1
)
await session.commit()
now = dt.datetime.now(tz=dt.UTC)
settings = get_settings()
await run_tick(session, None, now, settings)
status = (
await session.execute(
text("SELECT status FROM subscriptions WHERE id = :s"), {"s": sub_id}
)
).scalar_one()
assert status == "expired"
async def test_scheduler_rolls_after_successful_renewal(factory) -> None:
provider = FakeProvider(payments={})
async with factory() as session:
user_id = await _seed_user(session)
sub_id = await _seed_subscription(
session, user_id, auto_renew=True, period_end_offset_days=-1
)
# Pre-create a succeeded renewal invoice.
inv_id = uuid.uuid4()
now = dt.datetime.now(tz=dt.UTC)
await session.execute(
text(
"INSERT INTO invoices (id, user_id, amount, status, kind, subscription_id, paid_at) "
"VALUES (:id, :u, 149000, 'succeeded', 'renewal', :s, :paid)"
),
{"id": inv_id, "u": user_id, "s": sub_id, "paid": now - dt.timedelta(minutes=1)},
)
await session.commit()
await run_tick(session, provider, now, settings=get_settings())
row = (
await session.execute(
text(
"SELECT status, current_period_start, current_period_end, origin_invoice_id "
"FROM subscriptions WHERE id = :s"
),
{"s": sub_id},
)
).first()
assert row[0] == "active"
assert row[3] == inv_id
assert row[2] > row[1]
async def test_scheduler_reconciles_pending_invoice(factory) -> None:
provider = FakeProvider(payments={})
async with factory() as session:
user_id = await _seed_user(session)
inv_id = uuid.uuid4()
old = dt.datetime.now(tz=dt.UTC) - dt.timedelta(minutes=20)
await session.execute(
text(
"INSERT INTO invoices (id, user_id, amount, status, kind, external_id, created_at) "
"VALUES (:id, :u, 19900, 'pending', 'topup', :ext, :created)"
),
{"id": inv_id, "u": user_id, "ext": "pay-old", "created": old},
)
provider.payments["pay-old"] = {
"amount_kopecks": 19900,
"status": "pending",
"metadata": {"invoice_id": str(inv_id), "kind": "topup"},
}
await session.commit()
provider.status_overrides["pay-old"] = "succeeded" # type: ignore[index]
await run_tick(session, provider, dt.datetime.now(tz=dt.UTC), settings=get_settings())
status = (
await session.execute(
text("SELECT status FROM invoices WHERE id = :id"), {"id": inv_id}
)
).scalar_one()
assert status == "succeeded"