diff --git a/tests/test_api_plans.py b/tests/test_api_plans.py new file mode 100644 index 00000000..54304577 --- /dev/null +++ b/tests/test_api_plans.py @@ -0,0 +1,987 @@ +"""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