Files
2026-03-07 15:06:48 +00:00

988 lines
36 KiB
Python

"""Tests for the subscription plans API endpoints (app/api/plans.py).
Covers:
- Unit tests for _require_admin, _plan_to_response, _apply_body helpers
- Auth enforcement (403 for non-admins on admin-only endpoints)
- GET /api/plans/ — list active plans (public)
- GET /api/plans/admin — list all plans inc. inactive (admin only)
- POST /api/plans/seed — seed default plans (admin only)
- POST /api/plans/reorder — reorder plans (admin only)
- POST /api/plans/ — create plan (admin only)
- GET /api/plans/{plan_id} — get single active plan (public)
- PUT /api/plans/{plan_id} — update plan (admin only)
- DELETE /api/plans/{plan_id}— delete plan (admin only)
"""
import json
from unittest.mock import MagicMock, patch
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models import SubscriptionPlan
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
ADMIN_USER = {"email": "admin@test.com", "is_admin": True, "name": "Admin"}
NONADMIN_USER = {"email": "user@test.com", "is_admin": False, "name": "User"}
@pytest.fixture()
def plans_engine():
"""In-memory SQLite engine for plans tests."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def plans_session(plans_engine):
"""DB session scoped to one test."""
Session = sessionmaker(bind=plans_engine)
session = Session()
yield session
session.close()
@pytest.fixture()
def admin_client(plans_engine):
"""TestClient that uses an in-memory DB and overrides _require_admin to allow admin access."""
from app.api.plans import _require_admin
from app.main import app
def override_db():
Session = sessionmaker(bind=plans_engine)
session = Session()
try:
yield session
finally:
session.close()
def override_require_admin():
return ADMIN_USER
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[_require_admin] = override_require_admin
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as c:
yield c
app.dependency_overrides.clear()
@pytest.fixture()
def nonadmin_client(plans_engine):
"""TestClient without admin override — _require_admin will raise 403."""
from app.main import app
def override_db():
Session = sessionmaker(bind=plans_engine)
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = override_db
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as c:
yield c
app.dependency_overrides.clear()
@pytest.fixture()
def public_client(plans_engine):
"""TestClient without any session override (no admin, no session user)."""
from app.main import app
def override_db():
Session = sessionmaker(bind=plans_engine)
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = override_db
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as c:
yield c
app.dependency_overrides.clear()
def _make_plan(
session, plan_id: str, name: str = "Test Plan", is_active: bool = True, sort_order: int = 0
) -> SubscriptionPlan:
"""Helper to insert a SubscriptionPlan row."""
plan = SubscriptionPlan(
plan_id=plan_id,
name=name,
price_monthly=9.99,
price_yearly=99.99,
is_active=is_active,
sort_order=sort_order,
)
session.add(plan)
session.commit()
session.refresh(plan)
return plan
# ---------------------------------------------------------------------------
# Unit tests — _require_admin helper
# ---------------------------------------------------------------------------
class TestRequireAdmin:
"""Unit tests for the _require_admin dependency function."""
@pytest.mark.unit
def test_require_admin_raises_403_when_no_user_in_session(self):
"""_require_admin raises 403 when session has no user key."""
from app.api.plans import _require_admin
mock_request = MagicMock()
mock_request.session = {}
with pytest.raises(HTTPException) as exc_info:
_require_admin(mock_request)
assert exc_info.value.status_code == 403
@pytest.mark.unit
def test_require_admin_raises_403_for_non_admin_user(self):
"""_require_admin raises 403 when user.is_admin is False."""
from app.api.plans import _require_admin
mock_request = MagicMock()
mock_request.session = {"user": NONADMIN_USER}
with pytest.raises(HTTPException) as exc_info:
_require_admin(mock_request)
assert exc_info.value.status_code == 403
@pytest.mark.unit
def test_require_admin_returns_user_dict_for_admin(self):
"""_require_admin returns the user dict when is_admin is True."""
from app.api.plans import _require_admin
mock_request = MagicMock()
mock_request.session = {"user": ADMIN_USER}
result = _require_admin(mock_request)
assert result == ADMIN_USER
@pytest.mark.unit
def test_require_admin_raises_403_when_user_is_none(self):
"""_require_admin raises 403 when session user is None."""
from app.api.plans import _require_admin
mock_request = MagicMock()
mock_request.session = {"user": None}
with pytest.raises(HTTPException) as exc_info:
_require_admin(mock_request)
assert exc_info.value.status_code == 403
# ---------------------------------------------------------------------------
# Unit tests — _plan_to_response helper
# ---------------------------------------------------------------------------
class TestPlanToResponse:
"""Unit tests for _plan_to_response."""
@pytest.mark.unit
def test_plan_to_response_returns_all_fields(self):
"""_plan_to_response includes all expected keys."""
from app.api.plans import _plan_to_response
plan = MagicMock(spec=SubscriptionPlan)
plan.id = 1
plan.plan_id = "free"
plan.name = "Free"
plan.tagline = "Always free"
plan.price_monthly = 0.0
plan.price_yearly = 0.0
plan.trial_days = 0
plan.lifetime_file_limit = 50
plan.daily_upload_limit = 5
plan.monthly_upload_limit = 20
plan.max_storage_destinations = 1
plan.max_ocr_pages_monthly = 150
plan.max_file_size_mb = 10
plan.max_mailboxes = 0
plan.overage_percent = 20
plan.allow_overage_billing = False
plan.overage_price_per_doc = None
plan.overage_price_per_ocr_page = None
plan.is_active = True
plan.is_highlighted = False
plan.badge_text = None
plan.cta_text = "Get started"
plan.sort_order = 0
plan.features = json.dumps(["feature1", "feature2"])
plan.api_access = False
plan.created_at = None
plan.updated_at = None
result = _plan_to_response(plan)
expected_keys = [
"id",
"plan_id",
"name",
"tagline",
"price_monthly",
"price_yearly",
"trial_days",
"lifetime_file_limit",
"daily_upload_limit",
"monthly_upload_limit",
"max_storage_destinations",
"max_ocr_pages_monthly",
"max_file_size_mb",
"max_mailboxes",
"overage_percent",
"allow_overage_billing",
"overage_price_per_doc",
"overage_price_per_ocr_page",
"is_active",
"is_highlighted",
"badge_text",
"cta_text",
"sort_order",
"features",
"api_access",
"created_at",
"updated_at",
]
for key in expected_keys:
assert key in result, f"Missing key: {key}"
assert result["features"] == ["feature1", "feature2"]
assert result["plan_id"] == "free"
@pytest.mark.unit
def test_plan_to_response_handles_null_features(self):
"""_plan_to_response returns empty list when features is None."""
from app.api.plans import _plan_to_response
plan = MagicMock(spec=SubscriptionPlan)
plan.id = 1
plan.plan_id = "free"
plan.name = "Free"
plan.tagline = None
plan.price_monthly = 0.0
plan.price_yearly = 0.0
plan.trial_days = 0
plan.lifetime_file_limit = 0
plan.daily_upload_limit = 0
plan.monthly_upload_limit = 0
plan.max_storage_destinations = 0
plan.max_ocr_pages_monthly = 0
plan.max_file_size_mb = 0
plan.max_mailboxes = 0
plan.overage_percent = 20
plan.allow_overage_billing = False
plan.overage_price_per_doc = None
plan.overage_price_per_ocr_page = None
plan.is_active = True
plan.is_highlighted = False
plan.badge_text = None
plan.cta_text = "Get started"
plan.sort_order = 0
plan.features = None
plan.api_access = False
plan.created_at = None
plan.updated_at = None
result = _plan_to_response(plan)
assert result["features"] == []
@pytest.mark.unit
def test_plan_to_response_handles_invalid_json_features(self):
"""_plan_to_response returns empty list when features JSON is invalid."""
from app.api.plans import _plan_to_response
plan = MagicMock(spec=SubscriptionPlan)
plan.id = 1
plan.plan_id = "free"
plan.name = "Free"
plan.tagline = None
plan.price_monthly = 0.0
plan.price_yearly = 0.0
plan.trial_days = 0
plan.lifetime_file_limit = 0
plan.daily_upload_limit = 0
plan.monthly_upload_limit = 0
plan.max_storage_destinations = 0
plan.max_ocr_pages_monthly = 0
plan.max_file_size_mb = 0
plan.max_mailboxes = 0
plan.overage_percent = 20
plan.allow_overage_billing = False
plan.overage_price_per_doc = None
plan.overage_price_per_ocr_page = None
plan.is_active = True
plan.is_highlighted = False
plan.badge_text = None
plan.cta_text = "Get started"
plan.sort_order = 0
plan.features = "not-valid-json{"
plan.api_access = False
plan.created_at = None
plan.updated_at = None
result = _plan_to_response(plan)
assert result["features"] == []
@pytest.mark.unit
def test_plan_to_response_formats_datetimes_as_isoformat(self):
"""_plan_to_response calls .isoformat() on created_at/updated_at when set."""
from datetime import datetime, timezone
from app.api.plans import _plan_to_response
plan = MagicMock(spec=SubscriptionPlan)
plan.id = 2
plan.plan_id = "starter"
plan.name = "Starter"
plan.tagline = None
plan.price_monthly = 2.99
plan.price_yearly = 29.99
plan.trial_days = 30
plan.lifetime_file_limit = 0
plan.daily_upload_limit = 0
plan.monthly_upload_limit = 50
plan.max_storage_destinations = 2
plan.max_ocr_pages_monthly = 300
plan.max_file_size_mb = 25
plan.max_mailboxes = 1
plan.overage_percent = 20
plan.allow_overage_billing = False
plan.overage_price_per_doc = None
plan.overage_price_per_ocr_page = None
plan.is_active = True
plan.is_highlighted = False
plan.badge_text = None
plan.cta_text = "Get started"
plan.sort_order = 1
plan.features = "[]"
plan.api_access = True
now = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
plan.created_at = now
plan.updated_at = now
result = _plan_to_response(plan)
assert result["created_at"] == now.isoformat()
assert result["updated_at"] == now.isoformat()
# ---------------------------------------------------------------------------
# Unit tests — _apply_body helper
# ---------------------------------------------------------------------------
class TestApplyBody:
"""Unit tests for the _apply_body helper."""
@pytest.mark.unit
def test_apply_body_sets_all_fields(self):
"""_apply_body copies all PlanUpsert fields onto the plan ORM object."""
from app.api.plans import PlanUpsert, _apply_body
plan = MagicMock(spec=SubscriptionPlan)
body = PlanUpsert(
name="Pro Plan",
tagline="Best value",
price_monthly=9.99,
price_yearly=99.99,
trial_days=14,
lifetime_file_limit=0,
daily_upload_limit=10,
monthly_upload_limit=100,
max_storage_destinations=5,
max_ocr_pages_monthly=500,
max_file_size_mb=50,
max_mailboxes=3,
overage_percent=15,
allow_overage_billing=True,
overage_price_per_doc=0.10,
overage_price_per_ocr_page=0.05,
is_active=True,
is_highlighted=True,
badge_text="Popular",
cta_text="Start now",
sort_order=2,
features=["Feature A", "Feature B"],
api_access=True,
)
_apply_body(plan, body)
assert plan.name == "Pro Plan"
assert plan.tagline == "Best value"
assert plan.price_monthly == 9.99
assert plan.price_yearly == 99.99
assert plan.trial_days == 14
assert plan.lifetime_file_limit == 0
assert plan.daily_upload_limit == 10
assert plan.monthly_upload_limit == 100
assert plan.max_storage_destinations == 5
assert plan.max_ocr_pages_monthly == 500
assert plan.max_file_size_mb == 50
assert plan.max_mailboxes == 3
assert plan.overage_percent == 15
assert plan.allow_overage_billing is True
assert plan.overage_price_per_doc == 0.10
assert plan.overage_price_per_ocr_page == 0.05
assert plan.is_active is True
assert plan.is_highlighted is True
assert plan.badge_text == "Popular"
assert plan.cta_text == "Start now"
assert plan.sort_order == 2
assert plan.features == json.dumps(["Feature A", "Feature B"])
assert plan.api_access is True
# ---------------------------------------------------------------------------
# Auth enforcement tests
# ---------------------------------------------------------------------------
class TestPlansAuth:
"""Admin-only endpoints must return 403 for non-admin requests."""
@pytest.mark.integration
def test_list_all_plans_requires_admin(self, nonadmin_client):
"""GET /api/plans/admin returns 403 for non-admins."""
resp = nonadmin_client.get("/api/plans/admin")
assert resp.status_code == 403
@pytest.mark.integration
def test_seed_plans_requires_admin(self, nonadmin_client):
"""POST /api/plans/seed returns 403 for non-admins."""
resp = nonadmin_client.post("/api/plans/seed")
assert resp.status_code == 403
@pytest.mark.integration
def test_reorder_plans_requires_admin(self, nonadmin_client):
"""POST /api/plans/reorder returns 403 for non-admins."""
resp = nonadmin_client.post("/api/plans/reorder", json={"order": []})
assert resp.status_code == 403
@pytest.mark.integration
def test_create_plan_requires_admin(self, nonadmin_client):
"""POST /api/plans/ returns 403 for non-admins."""
resp = nonadmin_client.post(
"/api/plans/",
params={"plan_id": "test"},
json={"name": "Test", "features": []},
)
assert resp.status_code == 403
@pytest.mark.integration
def test_update_plan_requires_admin(self, nonadmin_client):
"""PUT /api/plans/{plan_id} returns 403 for non-admins."""
resp = nonadmin_client.put(
"/api/plans/free",
json={"name": "Free Updated", "features": []},
)
assert resp.status_code == 403
@pytest.mark.integration
def test_delete_plan_requires_admin(self, nonadmin_client):
"""DELETE /api/plans/{plan_id} returns 403 for non-admins."""
resp = nonadmin_client.delete("/api/plans/free")
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# GET /api/plans/ — list active plans (public)
# ---------------------------------------------------------------------------
class TestListActivePlans:
"""Tests for the public list-active-plans endpoint."""
@pytest.mark.integration
def test_list_active_plans_empty(self, public_client):
"""Returns empty list when no plans exist."""
resp = public_client.get("/api/plans/")
assert resp.status_code == 200
data = resp.json()
assert data == {"plans": []}
@pytest.mark.integration
def test_list_active_plans_returns_only_active(self, public_client, plans_session):
"""Only active plans are returned."""
_make_plan(plans_session, "active_plan", "Active Plan", is_active=True)
_make_plan(plans_session, "inactive_plan", "Inactive Plan", is_active=False)
resp = public_client.get("/api/plans/")
assert resp.status_code == 200
data = resp.json()
ids = [p["plan_id"] for p in data["plans"]]
assert "active_plan" in ids
assert "inactive_plan" not in ids
@pytest.mark.integration
def test_list_active_plans_sorted_by_sort_order(self, public_client, plans_session):
"""Plans are returned in ascending sort_order."""
_make_plan(plans_session, "plan_b", "Plan B", sort_order=2)
_make_plan(plans_session, "plan_a", "Plan A", sort_order=1)
_make_plan(plans_session, "plan_c", "Plan C", sort_order=3)
resp = public_client.get("/api/plans/")
assert resp.status_code == 200
ids = [p["plan_id"] for p in resp.json()["plans"]]
assert ids == ["plan_a", "plan_b", "plan_c"]
@pytest.mark.integration
def test_list_active_plans_response_fields(self, public_client, plans_session):
"""Each plan in response has expected fields."""
_make_plan(plans_session, "free", "Free Plan")
resp = public_client.get("/api/plans/")
plan = resp.json()["plans"][0]
for field in ("plan_id", "name", "price_monthly", "is_active", "features"):
assert field in plan, f"Missing field: {field}"
# ---------------------------------------------------------------------------
# GET /api/plans/admin — list all plans (admin only)
# ---------------------------------------------------------------------------
class TestListAllPlans:
"""Tests for the admin list-all-plans endpoint."""
@pytest.mark.integration
def test_list_all_plans_empty(self, admin_client):
"""Returns empty list when no plans exist."""
resp = admin_client.get("/api/plans/admin")
assert resp.status_code == 200
assert resp.json() == {"plans": []}
@pytest.mark.integration
def test_list_all_plans_includes_inactive(self, admin_client, plans_session):
"""Admin endpoint returns both active and inactive plans."""
_make_plan(plans_session, "active_plan", "Active", is_active=True)
_make_plan(plans_session, "inactive_plan", "Inactive", is_active=False)
resp = admin_client.get("/api/plans/admin")
assert resp.status_code == 200
ids = [p["plan_id"] for p in resp.json()["plans"]]
assert "active_plan" in ids
assert "inactive_plan" in ids
@pytest.mark.integration
def test_list_all_plans_sorted_by_sort_order(self, admin_client, plans_session):
"""Plans are returned in ascending sort_order."""
_make_plan(plans_session, "plan_z", "Plan Z", sort_order=10)
_make_plan(plans_session, "plan_a", "Plan A", sort_order=1)
resp = admin_client.get("/api/plans/admin")
ids = [p["plan_id"] for p in resp.json()["plans"]]
assert ids.index("plan_a") < ids.index("plan_z")
# ---------------------------------------------------------------------------
# POST /api/plans/seed — seed default plans (admin only)
# ---------------------------------------------------------------------------
class TestSeedPlans:
"""Tests for the seed-plans endpoint."""
@pytest.mark.integration
def test_seed_plans_inserts_defaults_when_empty(self, admin_client):
"""Seeding on an empty table inserts default plans."""
with patch("app.utils.subscription.seed_default_plans", return_value=4) as mock_seed:
resp = admin_client.post("/api/plans/seed")
assert resp.status_code == 200
data = resp.json()
assert data["inserted"] == 4
assert "4" in data["message"]
mock_seed.assert_called_once()
@pytest.mark.integration
def test_seed_plans_noop_when_already_seeded(self, admin_client):
"""Seeding returns 0 inserted when plans already exist."""
with patch("app.utils.subscription.seed_default_plans", return_value=0) as mock_seed:
resp = admin_client.post("/api/plans/seed")
assert resp.status_code == 200
data = resp.json()
assert data["inserted"] == 0
mock_seed.assert_called_once()
# ---------------------------------------------------------------------------
# POST /api/plans/reorder — reorder plans (admin only)
# ---------------------------------------------------------------------------
class TestReorderPlans:
"""Tests for the reorder-plans endpoint."""
@pytest.mark.integration
def test_reorder_updates_sort_order(self, admin_client, plans_session):
"""Reordering updates sort_order of existing plans."""
_make_plan(plans_session, "plan_a", "Plan A", sort_order=0)
_make_plan(plans_session, "plan_b", "Plan B", sort_order=1)
resp = admin_client.post(
"/api/plans/reorder",
json={"order": ["plan_b", "plan_a"]},
)
assert resp.status_code == 200
data = resp.json()
assert data["updated"] == 2
plans_session.expire_all()
plan_b = plans_session.query(SubscriptionPlan).filter_by(plan_id="plan_b").first()
plan_a = plans_session.query(SubscriptionPlan).filter_by(plan_id="plan_a").first()
assert plan_b.sort_order == 0
assert plan_a.sort_order == 1
@pytest.mark.integration
def test_reorder_skips_unknown_plan_ids(self, admin_client, plans_session):
"""Unknown plan IDs in the order list are silently skipped."""
_make_plan(plans_session, "real_plan", "Real Plan", sort_order=5)
resp = admin_client.post(
"/api/plans/reorder",
json={"order": ["real_plan", "nonexistent_plan"]},
)
assert resp.status_code == 200
data = resp.json()
assert data["updated"] == 1
@pytest.mark.integration
def test_reorder_empty_order_list(self, admin_client):
"""Empty order list returns 0 updated."""
resp = admin_client.post("/api/plans/reorder", json={"order": []})
assert resp.status_code == 200
assert resp.json()["updated"] == 0
@pytest.mark.integration
def test_reorder_db_error_returns_500(self, admin_client, plans_session):
"""Database commit failure returns 500."""
_make_plan(plans_session, "plan_x", "Plan X")
with patch("sqlalchemy.orm.Session.commit", side_effect=Exception("DB error")):
resp = admin_client.post(
"/api/plans/reorder",
json={"order": ["plan_x"]},
)
assert resp.status_code == 500
# ---------------------------------------------------------------------------
# POST /api/plans/ — create plan (admin only)
# ---------------------------------------------------------------------------
class TestCreatePlan:
"""Tests for the create-plan endpoint."""
_plan_body = {
"name": "New Plan",
"tagline": "A great plan",
"price_monthly": 4.99,
"price_yearly": 49.99,
"trial_days": 14,
"lifetime_file_limit": 0,
"daily_upload_limit": 0,
"monthly_upload_limit": 50,
"max_storage_destinations": 3,
"max_ocr_pages_monthly": 300,
"max_file_size_mb": 25,
"max_mailboxes": 1,
"overage_percent": 20,
"allow_overage_billing": False,
"overage_price_per_doc": None,
"overage_price_per_ocr_page": None,
"is_active": True,
"is_highlighted": False,
"badge_text": None,
"cta_text": "Get started",
"sort_order": 0,
"features": ["Feature 1", "Feature 2"],
"api_access": False,
}
@pytest.mark.integration
def test_create_plan_success(self, admin_client):
"""POST /api/plans/ creates a new plan and returns 201."""
resp = admin_client.post(
"/api/plans/",
params={"plan_id": "new_plan"},
json=self._plan_body,
)
assert resp.status_code == 201
data = resp.json()
assert data["plan_id"] == "new_plan"
assert data["name"] == "New Plan"
assert data["features"] == ["Feature 1", "Feature 2"]
assert data["monthly_upload_limit"] == 50
@pytest.mark.integration
def test_create_plan_conflict_returns_409(self, admin_client, plans_session):
"""Creating a plan with an existing plan_id returns 409."""
_make_plan(plans_session, "existing_plan", "Existing")
resp = admin_client.post(
"/api/plans/",
params={"plan_id": "existing_plan"},
json=self._plan_body,
)
assert resp.status_code == 409
@pytest.mark.integration
def test_create_plan_returns_all_fields(self, admin_client):
"""Created plan response includes all expected fields."""
resp = admin_client.post(
"/api/plans/",
params={"plan_id": "full_plan"},
json=self._plan_body,
)
assert resp.status_code == 201
data = resp.json()
for field in (
"id",
"plan_id",
"name",
"tagline",
"price_monthly",
"price_yearly",
"is_active",
"features",
"sort_order",
"api_access",
):
assert field in data, f"Missing field: {field}"
@pytest.mark.integration
def test_create_plan_with_empty_features(self, admin_client):
"""Plan can be created with an empty features list."""
body = dict(self._plan_body)
body["features"] = []
resp = admin_client.post(
"/api/plans/",
params={"plan_id": "no_features_plan"},
json=body,
)
assert resp.status_code == 201
assert resp.json()["features"] == []
@pytest.mark.integration
def test_create_plan_api_access_flag(self, admin_client):
"""Plan can be created with api_access=True."""
body = dict(self._plan_body)
body["api_access"] = True
resp = admin_client.post(
"/api/plans/",
params={"plan_id": "api_plan"},
json=body,
)
assert resp.status_code == 201
assert resp.json()["api_access"] is True
@pytest.mark.integration
def test_create_plan_db_commit_error_returns_500(self, admin_client):
"""A database commit failure during create returns 500."""
with patch("sqlalchemy.orm.Session.commit", side_effect=Exception("DB failure")):
resp = admin_client.post(
"/api/plans/",
params={"plan_id": "bad_plan"},
json=self._plan_body,
)
assert resp.status_code == 500
# ---------------------------------------------------------------------------
# GET /api/plans/{plan_id} — get single active plan (public)
# ---------------------------------------------------------------------------
class TestGetPlan:
"""Tests for the get-single-plan endpoint."""
@pytest.mark.integration
def test_get_active_plan(self, public_client, plans_session):
"""Returns plan data for an existing active plan."""
_make_plan(plans_session, "starter", "Starter Plan", is_active=True)
resp = public_client.get("/api/plans/starter")
assert resp.status_code == 200
data = resp.json()
assert data["plan_id"] == "starter"
assert data["name"] == "Starter Plan"
@pytest.mark.integration
def test_get_inactive_plan_returns_404(self, public_client, plans_session):
"""Inactive plans are not found by the public endpoint."""
_make_plan(plans_session, "hidden_plan", "Hidden", is_active=False)
resp = public_client.get("/api/plans/hidden_plan")
assert resp.status_code == 404
@pytest.mark.integration
def test_get_nonexistent_plan_returns_404(self, public_client):
"""Non-existent plan_id returns 404."""
resp = public_client.get("/api/plans/does_not_exist")
assert resp.status_code == 404
@pytest.mark.integration
def test_get_plan_returns_features_list(self, public_client, plans_session):
"""Plan response includes a features list."""
plan = _make_plan(plans_session, "featured_plan", "Featured")
plan.features = json.dumps(["Alpha", "Beta"])
plans_session.commit()
resp = public_client.get("/api/plans/featured_plan")
assert resp.status_code == 200
assert resp.json()["features"] == ["Alpha", "Beta"]
# ---------------------------------------------------------------------------
# PUT /api/plans/{plan_id} — update plan (admin only)
# ---------------------------------------------------------------------------
class TestUpdatePlan:
"""Tests for the update-plan endpoint."""
_update_body = {
"name": "Updated Name",
"tagline": "New tagline",
"price_monthly": 19.99,
"price_yearly": 199.99,
"trial_days": 30,
"lifetime_file_limit": 0,
"daily_upload_limit": 0,
"monthly_upload_limit": 100,
"max_storage_destinations": 5,
"max_ocr_pages_monthly": 500,
"max_file_size_mb": 50,
"max_mailboxes": 3,
"overage_percent": 10,
"allow_overage_billing": True,
"overage_price_per_doc": 0.05,
"overage_price_per_ocr_page": 0.02,
"is_active": True,
"is_highlighted": True,
"badge_text": "Best",
"cta_text": "Upgrade now",
"sort_order": 2,
"features": ["Updated Feature"],
"api_access": True,
}
@pytest.mark.integration
def test_update_plan_success(self, admin_client, plans_session):
"""PUT /api/plans/{plan_id} updates the plan and returns 200."""
_make_plan(plans_session, "starter", "Starter")
resp = admin_client.put("/api/plans/starter", json=self._update_body)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "Updated Name"
assert data["price_monthly"] == 19.99
assert data["monthly_upload_limit"] == 100
assert data["features"] == ["Updated Feature"]
assert data["api_access"] is True
@pytest.mark.integration
def test_update_nonexistent_plan_returns_404(self, admin_client):
"""Updating a plan that doesn't exist returns 404."""
resp = admin_client.put("/api/plans/no_such_plan", json=self._update_body)
assert resp.status_code == 404
@pytest.mark.integration
def test_update_plan_persists_changes(self, admin_client, plans_session):
"""Changes made via PUT are persisted in the database."""
_make_plan(plans_session, "mutable_plan", "Original Name")
admin_client.put("/api/plans/mutable_plan", json=self._update_body)
plans_session.expire_all()
updated = plans_session.query(SubscriptionPlan).filter_by(plan_id="mutable_plan").first()
assert updated.name == "Updated Name"
assert updated.overage_percent == 10
@pytest.mark.integration
def test_update_plan_can_deactivate(self, admin_client, plans_session):
"""A plan can be deactivated via PUT."""
_make_plan(plans_session, "active_plan", "Active", is_active=True)
body = dict(self._update_body)
body["is_active"] = False
resp = admin_client.put("/api/plans/active_plan", json=body)
assert resp.status_code == 200
assert resp.json()["is_active"] is False
@pytest.mark.integration
def test_update_plan_db_commit_error_returns_500(self, admin_client, plans_session):
"""A database commit failure during update returns 500."""
_make_plan(plans_session, "fail_plan", "Fail Plan")
with patch("sqlalchemy.orm.Session.commit", side_effect=Exception("DB failure")):
resp = admin_client.put("/api/plans/fail_plan", json=self._update_body)
assert resp.status_code == 500
# ---------------------------------------------------------------------------
# DELETE /api/plans/{plan_id} — delete plan (admin only)
# ---------------------------------------------------------------------------
class TestDeletePlan:
"""Tests for the delete-plan endpoint."""
@pytest.mark.integration
def test_delete_plan_success(self, admin_client, plans_session):
"""DELETE /api/plans/{plan_id} removes the plan and returns 204."""
_make_plan(plans_session, "to_delete", "To Delete")
resp = admin_client.delete("/api/plans/to_delete")
assert resp.status_code == 204
@pytest.mark.integration
def test_delete_plan_actually_removed(self, admin_client, plans_session):
"""After deletion the plan no longer exists in the DB."""
_make_plan(plans_session, "goodbye_plan", "Goodbye")
admin_client.delete("/api/plans/goodbye_plan")
plans_session.expire_all()
plan = plans_session.query(SubscriptionPlan).filter_by(plan_id="goodbye_plan").first()
assert plan is None
@pytest.mark.integration
def test_delete_nonexistent_plan_returns_404(self, admin_client):
"""Deleting a plan that doesn't exist returns 404."""
resp = admin_client.delete("/api/plans/nonexistent")
assert resp.status_code == 404
@pytest.mark.integration
def test_delete_plan_does_not_affect_other_plans(self, admin_client, plans_session):
"""Deleting one plan does not remove other plans."""
_make_plan(plans_session, "plan_keep", "Keep This", sort_order=0)
_make_plan(plans_session, "plan_delete", "Delete This", sort_order=1)
admin_client.delete("/api/plans/plan_delete")
plans_session.expire_all()
kept = plans_session.query(SubscriptionPlan).filter_by(plan_id="plan_keep").first()
assert kept is not None
@pytest.mark.integration
def test_delete_plan_db_commit_error_returns_500(self, admin_client, plans_session):
"""A database commit failure during delete returns 500."""
_make_plan(plans_session, "fail_delete_plan", "Fail Delete")
with patch("sqlalchemy.orm.Session.commit", side_effect=Exception("DB failure")):
resp = admin_client.delete("/api/plans/fail_delete_plan")
assert resp.status_code == 500