identity-plan-kit 0.3.0__tar.gz → 0.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/Makefile +25 -2
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/PKG-INFO +1 -1
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/pyproject.toml +2 -2
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/cache/plan_cache.py +87 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/dependencies.py +7 -35
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/services/plan_service.py +8 -4
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/exception_handlers.py +6 -1
- identity_plan_kit-0.3.2/tests/plans/test_dependencies.py +344 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/plans/test_plan_cache_concurrent.py +105 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/uv.lock +1 -1
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/.gitignore +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/README.md +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/alembic/env.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/alembic/script.py.mako +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/alembic/versions/20250124_000000_initial_schema.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/alembic/versions/20250127_000000_add_password_hash.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/alembic.ini +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/integration/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/integration/fastapi_integration.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/integration/sample_alembic_env.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/integration/webhook_integration.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/playing/admin_usage.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/playing/basic_usage.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/playing/custom_error_formats.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/playing/extending_models.html +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/playing/extension_example.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/prod/.env.production.example +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/prod/Dockerfile +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/prod/docker-compose.yml +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/prod/nginx.conf +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/prod/production_setup.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/prod/prometheus.yml +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/loadtests/README.md +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/loadtests/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/loadtests/config.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/loadtests/locustfile.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/loadtests/test_auth.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/loadtests/test_database_stress.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/loadtests/test_mixed_scenarios.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/loadtests/test_plans_quota.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/loadtests/test_quota_direct.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/loadtests/test_rbac_cache.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/loadtests/utils.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/admin/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/admin/auth.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/admin/views.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/dependencies.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/domain/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/domain/entities.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/domain/exceptions.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/dto/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/dto/requests.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/dto/responses.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/handlers/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/handlers/oauth_routes.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/models/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/models/refresh_token.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/models/user.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/models/user_provider.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/repositories/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/repositories/token_repo.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/repositories/user_repo.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/services/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/services/auth_service.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/services/oauth_service.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/uow.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/cli.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/config.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/kit.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/migrations.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/cache/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/cache/user_plan_cache.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/domain/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/domain/entities.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/domain/exceptions.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/dto/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/dto/responses.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/dto/usage.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/handlers/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/handlers/plan_routes.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/models/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/models/feature.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/models/feature_usage.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/models/plan.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/models/plan_limit.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/models/plan_permission.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/models/user_plan.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/repositories/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/repositories/plan_repo.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/repositories/usage_repo.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/services/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/uow.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/cache/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/cache/permission_cache.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/dependencies.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/domain/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/domain/entities.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/domain/exceptions.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/models/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/models/permission.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/models/role.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/models/role_permission.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/repositories/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/repositories/rbac_repo.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/services/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/services/rbac_service.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/uow.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/README.md +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/audit.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/circuit_breaker.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/cleanup_scheduler.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/database.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/error_formatter.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/exceptions.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/graceful_shutdown.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/health.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/http_utils.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/lockout.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/logging.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/metrics.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/models.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/rate_limiter.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/request_id.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/schemas.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/security.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/state_store.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/uow.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/uuid7.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/auth/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/auth/test_auth_service.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/auth/test_circuit_breaker.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/auth/test_csrf_state.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/auth/test_dependencies.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/auth/test_domain_entities.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/auth/test_http_utils.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/auth/test_lockout.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/auth/test_lockout_concurrent.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/auth/test_oauth_service_resilience.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/auth/test_security_comprehensive.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/auth/test_token_security.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/conftest.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/conftest.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/test_concurrent_limit_merge.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/test_concurrent_plan_operations.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/test_concurrent_token_operations.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/test_concurrent_user_creation.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/test_concurrent_user_deactivation.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/test_limit_boundaries.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/test_plan_cache_race.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/test_quota_concurrency.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/test_token_cleanup.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/test_token_repository.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/test_usage_periods.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/test_user_repository.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/plans/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/plans/test_domain_entities.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/plans/test_period_edge_cases.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/plans/test_plan_service.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/rbac/__init__.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/rbac/test_permission_cache.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/rbac/test_permission_cache_concurrent.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/rbac/test_rbac_cache_stampede.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/rbac/test_rbac_service.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/shared/test_audit_pii_masking.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/shared/test_circuit_breaker_concurrent.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/shared/test_cleanup_scheduler_concurrent.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/shared/test_config_validation.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/shared/test_exception_handlers.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/shared/test_http_utils_security.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/shared/test_rate_limiter.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/shared/test_rate_limiter_concurrent.py +0 -0
- {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/shared/test_state_store_concurrent.py +0 -0
|
@@ -20,6 +20,9 @@ ENV_FILE ?= $(shell \
|
|
|
20
20
|
else echo ""; \
|
|
21
21
|
fi)
|
|
22
22
|
|
|
23
|
+
# PyPI config file (override with: make publish PYPIRC=path/to/.pypirc)
|
|
24
|
+
PYPIRC ?= $(HOME)/.pypirc
|
|
25
|
+
|
|
23
26
|
help: ## Show this help message
|
|
24
27
|
@echo "Usage: make [target]"
|
|
25
28
|
@echo ""
|
|
@@ -116,13 +119,33 @@ build-wheels: ## Build binary wheels for distribution
|
|
|
116
119
|
@echo "✓ Wheels built in dist/"
|
|
117
120
|
@ls -lh dist/*.whl
|
|
118
121
|
|
|
119
|
-
publish-test: build ## Publish package to TestPyPI
|
|
122
|
+
publish-test: build ## Publish package to TestPyPI (uses ~/.pypirc if present)
|
|
120
123
|
@echo "Publishing to TestPyPI..."
|
|
124
|
+
ifeq ($(wildcard $(PYPIRC)),)
|
|
121
125
|
$(UV) publish --index testpypi
|
|
126
|
+
else
|
|
127
|
+
@echo "Using credentials from $(PYPIRC)"
|
|
128
|
+
@TOKEN=$$(awk '/^\[testpypi\]/{found=1} found && /^password/{print $$3; exit}' $(PYPIRC)); \
|
|
129
|
+
if [ -n "$$TOKEN" ]; then \
|
|
130
|
+
$(UV) publish --index testpypi --token "$$TOKEN"; \
|
|
131
|
+
else \
|
|
132
|
+
$(UV) publish --index testpypi; \
|
|
133
|
+
fi
|
|
134
|
+
endif
|
|
122
135
|
|
|
123
|
-
publish: build ## Publish package to PyPI
|
|
136
|
+
publish: build ## Publish package to PyPI (uses ~/.pypirc if present)
|
|
124
137
|
@echo "Publishing to PyPI..."
|
|
138
|
+
ifeq ($(wildcard $(PYPIRC)),)
|
|
125
139
|
$(UV) publish
|
|
140
|
+
else
|
|
141
|
+
@echo "Using credentials from $(PYPIRC)"
|
|
142
|
+
@TOKEN=$$(awk '/^\[pypi\]/{found=1} found && /^password/{print $$3; exit}' $(PYPIRC)); \
|
|
143
|
+
if [ -n "$$TOKEN" ]; then \
|
|
144
|
+
$(UV) publish --token "$$TOKEN"; \
|
|
145
|
+
else \
|
|
146
|
+
$(UV) publish; \
|
|
147
|
+
fi
|
|
148
|
+
endif
|
|
126
149
|
|
|
127
150
|
ci: check test-cov ## Run all CI checks (lint, format, typecheck, tests with coverage)
|
|
128
151
|
@echo "✓ All CI checks passed!"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "identity-plan-kit"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.2"
|
|
4
4
|
description = "Modern FastAPI library for authentication, RBAC, subscription plans, and usage tracking"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -140,7 +140,7 @@ testpaths = ["tests"]
|
|
|
140
140
|
addopts = "-v --tb=short"
|
|
141
141
|
|
|
142
142
|
[tool.bumpversion]
|
|
143
|
-
current_version = "0.3.
|
|
143
|
+
current_version = "0.3.2"
|
|
144
144
|
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
|
145
145
|
serialize = ["{major}.{minor}.{patch}"]
|
|
146
146
|
tag = true
|
{identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/cache/plan_cache.py
RENAMED
|
@@ -27,6 +27,20 @@ class PlanCacheEntry:
|
|
|
27
27
|
return datetime.now(UTC) > self.expires_at
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
@dataclass
|
|
31
|
+
class AllPlansCacheEntry:
|
|
32
|
+
"""Cache entry for the complete list of all plans."""
|
|
33
|
+
|
|
34
|
+
plans: list[Plan]
|
|
35
|
+
expires_at: datetime
|
|
36
|
+
fetched_at: float = field(default_factory=time.monotonic)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def is_expired(self) -> bool:
|
|
40
|
+
"""Check if entry has expired."""
|
|
41
|
+
return datetime.now(UTC) > self.expires_at
|
|
42
|
+
|
|
43
|
+
|
|
30
44
|
class PlanCache:
|
|
31
45
|
"""
|
|
32
46
|
In-memory plan cache.
|
|
@@ -69,6 +83,8 @@ class PlanCache:
|
|
|
69
83
|
self._invalidated_at: dict[str, float] = {}
|
|
70
84
|
# Global invalidation timestamp for invalidate_all()
|
|
71
85
|
self._global_invalidated_at: float = 0.0
|
|
86
|
+
# Cache for the complete list of all plans
|
|
87
|
+
self._all_plans_cache: AllPlansCacheEntry | None = None
|
|
72
88
|
|
|
73
89
|
async def get(self, plan_code: str) -> Plan | None:
|
|
74
90
|
"""
|
|
@@ -174,12 +190,80 @@ class PlanCache:
|
|
|
174
190
|
self._cache[plan_code] = entry
|
|
175
191
|
return True
|
|
176
192
|
|
|
193
|
+
async def get_all(self) -> list[Plan] | None:
|
|
194
|
+
"""
|
|
195
|
+
Get cached list of all plans.
|
|
196
|
+
|
|
197
|
+
This operation is lock-free for better performance under high concurrency.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
List of all Plan entities or None if not cached/expired
|
|
201
|
+
"""
|
|
202
|
+
if not self._enabled:
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
entry = self._all_plans_cache
|
|
206
|
+
if entry is None:
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
if entry.is_expired:
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
logger.debug("all_plans_cache_hit", count=len(entry.plans))
|
|
213
|
+
return entry.plans
|
|
214
|
+
|
|
215
|
+
async def set_all(
|
|
216
|
+
self,
|
|
217
|
+
plans: list[Plan],
|
|
218
|
+
fetched_at: float | None = None,
|
|
219
|
+
) -> bool:
|
|
220
|
+
"""
|
|
221
|
+
Cache the complete list of all plans.
|
|
222
|
+
|
|
223
|
+
Also populates individual plan entries for single-plan lookups.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
plans: List of all Plan entities to cache
|
|
227
|
+
fetched_at: Monotonic timestamp when plans were fetched from DB.
|
|
228
|
+
If provided, rejects stale writes after invalidation.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
True if cached, False if rejected as stale
|
|
232
|
+
"""
|
|
233
|
+
if not self._enabled:
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
# Check for stale write if fetched_at is provided
|
|
237
|
+
if fetched_at is not None and fetched_at < self._global_invalidated_at:
|
|
238
|
+
logger.debug(
|
|
239
|
+
"all_plans_cache_stale_write_rejected",
|
|
240
|
+
reason="global_invalidation",
|
|
241
|
+
fetched_at=fetched_at,
|
|
242
|
+
invalidated_at=self._global_invalidated_at,
|
|
243
|
+
)
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
# Cache the all-plans list
|
|
247
|
+
self._all_plans_cache = AllPlansCacheEntry(
|
|
248
|
+
plans=plans,
|
|
249
|
+
expires_at=datetime.now(UTC) + self._ttl,
|
|
250
|
+
fetched_at=fetched_at or time.monotonic(),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Also populate individual plan entries
|
|
254
|
+
for plan in plans:
|
|
255
|
+
await self.set(plan.code, plan, fetched_at=fetched_at)
|
|
256
|
+
|
|
257
|
+
logger.debug("all_plans_cached", count=len(plans))
|
|
258
|
+
return True
|
|
259
|
+
|
|
177
260
|
async def invalidate(self, plan_code: str) -> None:
|
|
178
261
|
"""
|
|
179
262
|
Invalidate cached plan by code.
|
|
180
263
|
|
|
181
264
|
Records the invalidation timestamp to prevent stale writes from
|
|
182
265
|
concurrent requests that fetched data before invalidation.
|
|
266
|
+
Also invalidates the all-plans cache since it contains stale data.
|
|
183
267
|
|
|
184
268
|
Args:
|
|
185
269
|
plan_code: Plan code to invalidate
|
|
@@ -189,6 +273,8 @@ class PlanCache:
|
|
|
189
273
|
self._invalidated_at[plan_code] = time.monotonic()
|
|
190
274
|
# dict.pop() is atomic in Python
|
|
191
275
|
self._cache.pop(plan_code, None)
|
|
276
|
+
# Also invalidate the all-plans cache since it now contains stale data
|
|
277
|
+
self._all_plans_cache = None
|
|
192
278
|
logger.debug("plan_cache_invalidated", plan_code=plan_code)
|
|
193
279
|
|
|
194
280
|
async def invalidate_all(self) -> None:
|
|
@@ -202,6 +288,7 @@ class PlanCache:
|
|
|
202
288
|
# Record global invalidation timestamp BEFORE clearing
|
|
203
289
|
self._global_invalidated_at = time.monotonic()
|
|
204
290
|
self._cache.clear()
|
|
291
|
+
self._all_plans_cache = None
|
|
205
292
|
# Also clear key-specific invalidation timestamps to prevent memory growth
|
|
206
293
|
self._invalidated_at.clear()
|
|
207
294
|
logger.info("plan_cache_cleared")
|
{identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/dependencies.py
RENAMED
|
@@ -60,16 +60,9 @@ def requires_plan(plan_code: str | None = None) -> Callable[..., Any]:
|
|
|
60
60
|
detail=f"Required plan: {plan_code}",
|
|
61
61
|
)
|
|
62
62
|
|
|
63
|
-
except UserPlanNotFoundError:
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
detail="No active subscription plan",
|
|
67
|
-
) from None
|
|
68
|
-
except PlanExpiredError:
|
|
69
|
-
raise HTTPException(
|
|
70
|
-
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
|
71
|
-
detail="Subscription plan has expired",
|
|
72
|
-
) from None
|
|
63
|
+
except (UserPlanNotFoundError, PlanExpiredError):
|
|
64
|
+
# Let exception handlers process these with proper error codes
|
|
65
|
+
raise
|
|
73
66
|
|
|
74
67
|
return Depends(dependency)
|
|
75
68
|
|
|
@@ -156,31 +149,10 @@ def requires_feature(
|
|
|
156
149
|
|
|
157
150
|
return usage_info
|
|
158
151
|
|
|
159
|
-
except UserPlanNotFoundError:
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
) from None
|
|
164
|
-
except PlanExpiredError:
|
|
165
|
-
raise HTTPException(
|
|
166
|
-
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
|
167
|
-
detail="Subscription plan has expired",
|
|
168
|
-
) from None
|
|
169
|
-
except FeatureNotAvailableError as e:
|
|
170
|
-
raise HTTPException(
|
|
171
|
-
status_code=status.HTTP_403_FORBIDDEN,
|
|
172
|
-
detail=str(e),
|
|
173
|
-
) from None
|
|
174
|
-
except QuotaExceededError as e:
|
|
175
|
-
raise HTTPException(
|
|
176
|
-
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
177
|
-
detail=str(e),
|
|
178
|
-
headers={
|
|
179
|
-
"X-Quota-Limit": str(e.limit),
|
|
180
|
-
"X-Quota-Used": str(e.used),
|
|
181
|
-
"X-Quota-Remaining": str(e.remaining),
|
|
182
|
-
},
|
|
183
|
-
) from None
|
|
152
|
+
except (QuotaExceededError, UserPlanNotFoundError, PlanExpiredError, FeatureNotAvailableError):
|
|
153
|
+
# Let exception handlers process these with proper error codes
|
|
154
|
+
# Quota headers are added by quota_exceeded_handler
|
|
155
|
+
raise
|
|
184
156
|
|
|
185
157
|
return Depends(dependency)
|
|
186
158
|
|
|
@@ -1150,7 +1150,7 @@ class PlanService:
|
|
|
1150
1150
|
This is an optimized method that loads all plans with their
|
|
1151
1151
|
nested relationships in a minimal number of queries.
|
|
1152
1152
|
|
|
1153
|
-
Plans are cached
|
|
1153
|
+
Plans are cached as a complete list and individually for subsequent
|
|
1154
1154
|
single-plan lookups.
|
|
1155
1155
|
|
|
1156
1156
|
Args:
|
|
@@ -1159,15 +1159,19 @@ class PlanService:
|
|
|
1159
1159
|
Returns:
|
|
1160
1160
|
List of all Plan entities with permissions and limits
|
|
1161
1161
|
"""
|
|
1162
|
+
# Check cache first
|
|
1163
|
+
cached_plans = await self._plan_cache.get_all()
|
|
1164
|
+
if cached_plans is not None:
|
|
1165
|
+
return cached_plans
|
|
1166
|
+
|
|
1162
1167
|
# Get fetch timestamp BEFORE DB query for stale write prevention
|
|
1163
1168
|
fetch_ts = self._plan_cache.get_fetch_timestamp()
|
|
1164
1169
|
|
|
1165
1170
|
async with self._create_uow(session=session) as uow:
|
|
1166
1171
|
plans = await uow.plans.get_all_plans()
|
|
1167
1172
|
|
|
1168
|
-
# Cache
|
|
1169
|
-
|
|
1170
|
-
await self._plan_cache.set(plan.code, plan, fetched_at=fetch_ts)
|
|
1173
|
+
# Cache the complete list and individual plans
|
|
1174
|
+
await self._plan_cache.set_all(plans, fetched_at=fetch_ts)
|
|
1171
1175
|
|
|
1172
1176
|
logger.debug(
|
|
1173
1177
|
"all_plans_loaded",
|
|
@@ -295,7 +295,7 @@ async def quota_exceeded_handler(request: Request, exc: QuotaExceededError) -> J
|
|
|
295
295
|
feature=exc.feature_code,
|
|
296
296
|
path=request.url.path,
|
|
297
297
|
)
|
|
298
|
-
|
|
298
|
+
response = create_error_response(
|
|
299
299
|
request=request,
|
|
300
300
|
status_code=429,
|
|
301
301
|
code=exc.code,
|
|
@@ -307,6 +307,11 @@ async def quota_exceeded_handler(request: Request, exc: QuotaExceededError) -> J
|
|
|
307
307
|
"period": exc.period,
|
|
308
308
|
},
|
|
309
309
|
)
|
|
310
|
+
# Add quota headers for client visibility
|
|
311
|
+
response.headers["X-Quota-Limit"] = str(exc.limit)
|
|
312
|
+
response.headers["X-Quota-Used"] = str(exc.used)
|
|
313
|
+
response.headers["X-Quota-Remaining"] = str(exc.remaining)
|
|
314
|
+
return response
|
|
310
315
|
|
|
311
316
|
|
|
312
317
|
async def plan_expired_handler(request: Request, exc: PlanExpiredError) -> JSONResponse:
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Tests for plan dependencies (requires_plan, requires_feature).
|
|
2
|
+
|
|
3
|
+
These tests verify that domain exceptions propagate correctly through
|
|
4
|
+
the dependency layer with proper error codes and context, not generic
|
|
5
|
+
HTTPException codes.
|
|
6
|
+
|
|
7
|
+
The key bug this tests for: dependencies were catching domain exceptions
|
|
8
|
+
and re-raising them as HTTPException, which lost the specific error codes.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from datetime import date
|
|
12
|
+
from typing import Annotated
|
|
13
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
14
|
+
from uuid import uuid4
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
from fastapi import Depends, FastAPI, Response
|
|
18
|
+
from starlette.testclient import TestClient
|
|
19
|
+
|
|
20
|
+
from identity_plan_kit.auth.dependencies import get_current_user
|
|
21
|
+
from identity_plan_kit.auth.domain.entities import User
|
|
22
|
+
from identity_plan_kit.plans.dependencies import (
|
|
23
|
+
FeatureUsage,
|
|
24
|
+
requires_feature,
|
|
25
|
+
requires_plan,
|
|
26
|
+
)
|
|
27
|
+
from identity_plan_kit.plans.domain.exceptions import (
|
|
28
|
+
FeatureNotAvailableError,
|
|
29
|
+
PlanExpiredError,
|
|
30
|
+
QuotaExceededError,
|
|
31
|
+
UserPlanNotFoundError,
|
|
32
|
+
)
|
|
33
|
+
from identity_plan_kit.plans.dto.usage import UsageInfo
|
|
34
|
+
from identity_plan_kit.shared.exception_handlers import register_exception_handlers
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.fixture
|
|
38
|
+
def mock_user():
|
|
39
|
+
"""Create a mock authenticated user."""
|
|
40
|
+
return User(
|
|
41
|
+
id=uuid4(),
|
|
42
|
+
email="test@example.com",
|
|
43
|
+
role_id=uuid4(),
|
|
44
|
+
is_active=True,
|
|
45
|
+
created_at=date.today(),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.fixture
|
|
50
|
+
def mock_plan_service():
|
|
51
|
+
"""Create a mock plan service."""
|
|
52
|
+
return AsyncMock()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.fixture
|
|
56
|
+
def app_with_mocked_auth(mock_plan_service, mock_user):
|
|
57
|
+
"""Create a FastAPI app with properly mocked dependencies."""
|
|
58
|
+
app = FastAPI()
|
|
59
|
+
|
|
60
|
+
# Mock the kit on app state
|
|
61
|
+
mock_kit = MagicMock()
|
|
62
|
+
mock_kit.plan_service = mock_plan_service
|
|
63
|
+
app.state.identity_plan_kit = mock_kit
|
|
64
|
+
|
|
65
|
+
# Register exception handlers
|
|
66
|
+
register_exception_handlers(app)
|
|
67
|
+
|
|
68
|
+
# Override the auth dependency to return our mock user
|
|
69
|
+
app.dependency_overrides[get_current_user] = lambda: mock_user
|
|
70
|
+
|
|
71
|
+
return app
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class TestRequiresFeatureQuotaExceeded:
|
|
75
|
+
"""Test that QuotaExceededError propagates with correct error code."""
|
|
76
|
+
|
|
77
|
+
def test_quota_exceeded_returns_quota_exceeded_code(
|
|
78
|
+
self, app_with_mocked_auth, mock_plan_service
|
|
79
|
+
):
|
|
80
|
+
"""
|
|
81
|
+
QUOTA_EXCEEDED code should be returned, not RATE_LIMIT_EXCEEDED.
|
|
82
|
+
|
|
83
|
+
This was the bug: the dependency was converting QuotaExceededError
|
|
84
|
+
to HTTPException(429), which then got the generic RATE_LIMIT_EXCEEDED
|
|
85
|
+
code from http_exception_handler's status code mapping.
|
|
86
|
+
"""
|
|
87
|
+
app = app_with_mocked_auth
|
|
88
|
+
|
|
89
|
+
# Configure mock to raise QuotaExceededError
|
|
90
|
+
mock_plan_service.check_and_consume_quota.side_effect = QuotaExceededError(
|
|
91
|
+
feature_code="ai_generation",
|
|
92
|
+
limit=10,
|
|
93
|
+
used=10,
|
|
94
|
+
period="daily",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
@app.post("/test")
|
|
98
|
+
async def test_endpoint(
|
|
99
|
+
usage: Annotated[UsageInfo, requires_feature("ai_generation", consume=1)],
|
|
100
|
+
):
|
|
101
|
+
return {"success": True}
|
|
102
|
+
|
|
103
|
+
client = TestClient(app)
|
|
104
|
+
response = client.post("/test")
|
|
105
|
+
|
|
106
|
+
assert response.status_code == 429
|
|
107
|
+
data = response.json()
|
|
108
|
+
assert data["success"] is False
|
|
109
|
+
# THIS IS THE KEY ASSERTION - must be QUOTA_EXCEEDED, not RATE_LIMIT_EXCEEDED
|
|
110
|
+
assert data["error"]["code"] == "QUOTA_EXCEEDED"
|
|
111
|
+
assert data["error"]["context"]["feature"] == "ai_generation"
|
|
112
|
+
assert data["error"]["context"]["limit"] == 10
|
|
113
|
+
assert data["error"]["context"]["used"] == 10
|
|
114
|
+
assert data["error"]["context"]["period"] == "daily"
|
|
115
|
+
|
|
116
|
+
def test_quota_exceeded_includes_headers(
|
|
117
|
+
self, app_with_mocked_auth, mock_plan_service
|
|
118
|
+
):
|
|
119
|
+
"""X-Quota-* headers should be set on quota exceeded."""
|
|
120
|
+
app = app_with_mocked_auth
|
|
121
|
+
|
|
122
|
+
mock_plan_service.check_and_consume_quota.side_effect = QuotaExceededError(
|
|
123
|
+
feature_code="api_calls",
|
|
124
|
+
limit=100,
|
|
125
|
+
used=100,
|
|
126
|
+
period="monthly",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
@app.post("/test")
|
|
130
|
+
async def test_endpoint(
|
|
131
|
+
usage: Annotated[UsageInfo, requires_feature("api_calls", consume=1)],
|
|
132
|
+
):
|
|
133
|
+
return {"success": True}
|
|
134
|
+
|
|
135
|
+
client = TestClient(app)
|
|
136
|
+
response = client.post("/test")
|
|
137
|
+
|
|
138
|
+
assert response.status_code == 429
|
|
139
|
+
assert response.headers.get("X-Quota-Limit") == "100"
|
|
140
|
+
assert response.headers.get("X-Quota-Used") == "100"
|
|
141
|
+
assert response.headers.get("X-Quota-Remaining") == "0"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class TestRequiresFeatureUserPlanNotFound:
|
|
145
|
+
"""Test that UserPlanNotFoundError propagates with correct error code."""
|
|
146
|
+
|
|
147
|
+
def test_user_plan_not_found_returns_proper_error_code(
|
|
148
|
+
self, app_with_mocked_auth, mock_plan_service
|
|
149
|
+
):
|
|
150
|
+
"""USER_PLAN_NOT_FOUND code should be returned, not generic codes."""
|
|
151
|
+
app = app_with_mocked_auth
|
|
152
|
+
|
|
153
|
+
mock_plan_service.check_and_consume_quota.side_effect = UserPlanNotFoundError()
|
|
154
|
+
|
|
155
|
+
@app.post("/test")
|
|
156
|
+
async def test_endpoint(
|
|
157
|
+
usage: Annotated[UsageInfo, requires_feature("ai_generation", consume=1)],
|
|
158
|
+
):
|
|
159
|
+
return {"success": True}
|
|
160
|
+
|
|
161
|
+
client = TestClient(app)
|
|
162
|
+
response = client.post("/test")
|
|
163
|
+
|
|
164
|
+
assert response.status_code == 404
|
|
165
|
+
data = response.json()
|
|
166
|
+
assert data["success"] is False
|
|
167
|
+
assert data["error"]["code"] == "USER_PLAN_NOT_FOUND"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class TestRequiresFeaturePlanExpired:
|
|
171
|
+
"""Test that PlanExpiredError propagates with correct error code."""
|
|
172
|
+
|
|
173
|
+
def test_plan_expired_returns_proper_error_code(
|
|
174
|
+
self, app_with_mocked_auth, mock_plan_service
|
|
175
|
+
):
|
|
176
|
+
"""PLAN_EXPIRED code should be returned."""
|
|
177
|
+
app = app_with_mocked_auth
|
|
178
|
+
|
|
179
|
+
mock_plan_service.check_and_consume_quota.side_effect = PlanExpiredError()
|
|
180
|
+
|
|
181
|
+
@app.post("/test")
|
|
182
|
+
async def test_endpoint(
|
|
183
|
+
usage: Annotated[UsageInfo, requires_feature("ai_generation", consume=1)],
|
|
184
|
+
):
|
|
185
|
+
return {"success": True}
|
|
186
|
+
|
|
187
|
+
client = TestClient(app)
|
|
188
|
+
response = client.post("/test")
|
|
189
|
+
|
|
190
|
+
assert response.status_code == 403
|
|
191
|
+
data = response.json()
|
|
192
|
+
assert data["success"] is False
|
|
193
|
+
assert data["error"]["code"] == "PLAN_EXPIRED"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class TestRequiresFeatureNotAvailable:
|
|
197
|
+
"""Test that FeatureNotAvailableError propagates with correct error code."""
|
|
198
|
+
|
|
199
|
+
def test_feature_not_available_returns_proper_error_code(
|
|
200
|
+
self, app_with_mocked_auth, mock_plan_service
|
|
201
|
+
):
|
|
202
|
+
"""FEATURE_NOT_AVAILABLE code should be returned with context."""
|
|
203
|
+
app = app_with_mocked_auth
|
|
204
|
+
|
|
205
|
+
mock_plan_service.check_and_consume_quota.side_effect = FeatureNotAvailableError(
|
|
206
|
+
feature_code="premium_export",
|
|
207
|
+
plan_code="free",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
@app.post("/test")
|
|
211
|
+
async def test_endpoint(
|
|
212
|
+
usage: Annotated[UsageInfo, requires_feature("premium_export", consume=1)],
|
|
213
|
+
):
|
|
214
|
+
return {"success": True}
|
|
215
|
+
|
|
216
|
+
client = TestClient(app)
|
|
217
|
+
response = client.post("/test")
|
|
218
|
+
|
|
219
|
+
assert response.status_code == 403
|
|
220
|
+
data = response.json()
|
|
221
|
+
assert data["success"] is False
|
|
222
|
+
assert data["error"]["code"] == "FEATURE_NOT_AVAILABLE"
|
|
223
|
+
assert data["error"]["context"]["feature"] == "premium_export"
|
|
224
|
+
assert data["error"]["context"]["plan"] == "free"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class TestRequiresPlanErrors:
|
|
228
|
+
"""Test that requires_plan dependency propagates errors correctly."""
|
|
229
|
+
|
|
230
|
+
def test_user_plan_not_found_returns_proper_code(
|
|
231
|
+
self, app_with_mocked_auth, mock_plan_service
|
|
232
|
+
):
|
|
233
|
+
"""USER_PLAN_NOT_FOUND from requires_plan should have correct code."""
|
|
234
|
+
app = app_with_mocked_auth
|
|
235
|
+
|
|
236
|
+
mock_plan_service.get_user_plan.side_effect = UserPlanNotFoundError()
|
|
237
|
+
|
|
238
|
+
@app.get("/test")
|
|
239
|
+
async def test_endpoint(
|
|
240
|
+
_: None = requires_plan(),
|
|
241
|
+
):
|
|
242
|
+
return {"success": True}
|
|
243
|
+
|
|
244
|
+
client = TestClient(app)
|
|
245
|
+
response = client.get("/test")
|
|
246
|
+
|
|
247
|
+
assert response.status_code == 404
|
|
248
|
+
data = response.json()
|
|
249
|
+
assert data["success"] is False
|
|
250
|
+
assert data["error"]["code"] == "USER_PLAN_NOT_FOUND"
|
|
251
|
+
|
|
252
|
+
def test_plan_expired_returns_proper_code(
|
|
253
|
+
self, app_with_mocked_auth, mock_plan_service
|
|
254
|
+
):
|
|
255
|
+
"""PLAN_EXPIRED from requires_plan should have correct code."""
|
|
256
|
+
app = app_with_mocked_auth
|
|
257
|
+
|
|
258
|
+
mock_plan_service.get_user_plan.side_effect = PlanExpiredError()
|
|
259
|
+
|
|
260
|
+
@app.get("/test")
|
|
261
|
+
async def test_endpoint(
|
|
262
|
+
_: None = requires_plan(),
|
|
263
|
+
):
|
|
264
|
+
return {"success": True}
|
|
265
|
+
|
|
266
|
+
client = TestClient(app)
|
|
267
|
+
response = client.get("/test")
|
|
268
|
+
|
|
269
|
+
assert response.status_code == 403
|
|
270
|
+
data = response.json()
|
|
271
|
+
assert data["success"] is False
|
|
272
|
+
assert data["error"]["code"] == "PLAN_EXPIRED"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class TestSuccessfulFeatureAccess:
|
|
276
|
+
"""Test successful feature access returns usage info."""
|
|
277
|
+
|
|
278
|
+
def test_successful_quota_consumption(
|
|
279
|
+
self, app_with_mocked_auth, mock_plan_service
|
|
280
|
+
):
|
|
281
|
+
"""Successful quota consumption should return usage info and headers."""
|
|
282
|
+
app = app_with_mocked_auth
|
|
283
|
+
|
|
284
|
+
mock_plan_service.check_and_consume_quota.return_value = UsageInfo(
|
|
285
|
+
feature_code="ai_generation",
|
|
286
|
+
used=5,
|
|
287
|
+
limit=10,
|
|
288
|
+
remaining=5,
|
|
289
|
+
period="daily",
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
@app.post("/test")
|
|
293
|
+
async def test_endpoint(
|
|
294
|
+
usage: Annotated[UsageInfo, requires_feature("ai_generation", consume=1)],
|
|
295
|
+
):
|
|
296
|
+
return {
|
|
297
|
+
"success": True,
|
|
298
|
+
"remaining": usage.remaining,
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
client = TestClient(app)
|
|
302
|
+
response = client.post("/test")
|
|
303
|
+
|
|
304
|
+
assert response.status_code == 200
|
|
305
|
+
data = response.json()
|
|
306
|
+
assert data["success"] is True
|
|
307
|
+
assert data["remaining"] == 5
|
|
308
|
+
|
|
309
|
+
# Check headers
|
|
310
|
+
assert response.headers.get("X-Quota-Limit") == "10"
|
|
311
|
+
assert response.headers.get("X-Quota-Used") == "5"
|
|
312
|
+
assert response.headers.get("X-Quota-Remaining") == "5"
|
|
313
|
+
assert response.headers.get("X-Quota-Period") == "daily"
|
|
314
|
+
|
|
315
|
+
def test_get_usage_info_only(
|
|
316
|
+
self, app_with_mocked_auth, mock_plan_service
|
|
317
|
+
):
|
|
318
|
+
"""consume=0 should just check access without consuming."""
|
|
319
|
+
app = app_with_mocked_auth
|
|
320
|
+
|
|
321
|
+
mock_plan_service.get_usage_info.return_value = UsageInfo(
|
|
322
|
+
feature_code="exports",
|
|
323
|
+
used=2,
|
|
324
|
+
limit=5,
|
|
325
|
+
remaining=3,
|
|
326
|
+
period="monthly",
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
@app.get("/test")
|
|
330
|
+
async def test_endpoint(
|
|
331
|
+
usage: Annotated[UsageInfo, requires_feature("exports", consume=0)],
|
|
332
|
+
):
|
|
333
|
+
return {
|
|
334
|
+
"success": True,
|
|
335
|
+
"remaining": usage.remaining,
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
client = TestClient(app)
|
|
339
|
+
response = client.get("/test")
|
|
340
|
+
|
|
341
|
+
assert response.status_code == 200
|
|
342
|
+
# Verify get_usage_info was called, not check_and_consume_quota
|
|
343
|
+
mock_plan_service.get_usage_info.assert_called_once()
|
|
344
|
+
mock_plan_service.check_and_consume_quota.assert_not_called()
|