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.
Files changed (179) hide show
  1. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/Makefile +25 -2
  2. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/PKG-INFO +1 -1
  3. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/pyproject.toml +2 -2
  4. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/cache/plan_cache.py +87 -0
  5. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/dependencies.py +7 -35
  6. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/services/plan_service.py +8 -4
  7. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/exception_handlers.py +6 -1
  8. identity_plan_kit-0.3.2/tests/plans/test_dependencies.py +344 -0
  9. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/plans/test_plan_cache_concurrent.py +105 -0
  10. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/uv.lock +1 -1
  11. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/.gitignore +0 -0
  12. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/README.md +0 -0
  13. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/alembic/env.py +0 -0
  14. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/alembic/script.py.mako +0 -0
  15. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/alembic/versions/20250124_000000_initial_schema.py +0 -0
  16. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/alembic/versions/20250127_000000_add_password_hash.py +0 -0
  17. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/alembic.ini +0 -0
  18. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/integration/__init__.py +0 -0
  19. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/integration/fastapi_integration.py +0 -0
  20. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/integration/sample_alembic_env.py +0 -0
  21. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/integration/webhook_integration.py +0 -0
  22. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/playing/admin_usage.py +0 -0
  23. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/playing/basic_usage.py +0 -0
  24. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/playing/custom_error_formats.py +0 -0
  25. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/playing/extending_models.html +0 -0
  26. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/playing/extension_example.py +0 -0
  27. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/prod/.env.production.example +0 -0
  28. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/prod/Dockerfile +0 -0
  29. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/prod/docker-compose.yml +0 -0
  30. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/prod/nginx.conf +0 -0
  31. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/prod/production_setup.py +0 -0
  32. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/examples/prod/prometheus.yml +0 -0
  33. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/loadtests/README.md +0 -0
  34. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/loadtests/__init__.py +0 -0
  35. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/loadtests/config.py +0 -0
  36. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/loadtests/locustfile.py +0 -0
  37. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/loadtests/test_auth.py +0 -0
  38. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/loadtests/test_database_stress.py +0 -0
  39. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/loadtests/test_mixed_scenarios.py +0 -0
  40. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/loadtests/test_plans_quota.py +0 -0
  41. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/loadtests/test_quota_direct.py +0 -0
  42. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/loadtests/test_rbac_cache.py +0 -0
  43. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/loadtests/utils.py +0 -0
  44. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/__init__.py +0 -0
  45. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/admin/__init__.py +0 -0
  46. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/admin/auth.py +0 -0
  47. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/admin/views.py +0 -0
  48. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/__init__.py +0 -0
  49. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/dependencies.py +0 -0
  50. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/domain/__init__.py +0 -0
  51. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/domain/entities.py +0 -0
  52. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/domain/exceptions.py +0 -0
  53. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/dto/__init__.py +0 -0
  54. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/dto/requests.py +0 -0
  55. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/dto/responses.py +0 -0
  56. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/handlers/__init__.py +0 -0
  57. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/handlers/oauth_routes.py +0 -0
  58. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/models/__init__.py +0 -0
  59. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/models/refresh_token.py +0 -0
  60. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/models/user.py +0 -0
  61. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/models/user_provider.py +0 -0
  62. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/repositories/__init__.py +0 -0
  63. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/repositories/token_repo.py +0 -0
  64. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/repositories/user_repo.py +0 -0
  65. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/services/__init__.py +0 -0
  66. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/services/auth_service.py +0 -0
  67. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/services/oauth_service.py +0 -0
  68. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/auth/uow.py +0 -0
  69. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/cli.py +0 -0
  70. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/config.py +0 -0
  71. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/kit.py +0 -0
  72. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/migrations.py +0 -0
  73. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/__init__.py +0 -0
  74. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/cache/__init__.py +0 -0
  75. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/cache/user_plan_cache.py +0 -0
  76. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/domain/__init__.py +0 -0
  77. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/domain/entities.py +0 -0
  78. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/domain/exceptions.py +0 -0
  79. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/dto/__init__.py +0 -0
  80. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/dto/responses.py +0 -0
  81. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/dto/usage.py +0 -0
  82. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/handlers/__init__.py +0 -0
  83. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/handlers/plan_routes.py +0 -0
  84. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/models/__init__.py +0 -0
  85. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/models/feature.py +0 -0
  86. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/models/feature_usage.py +0 -0
  87. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/models/plan.py +0 -0
  88. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/models/plan_limit.py +0 -0
  89. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/models/plan_permission.py +0 -0
  90. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/models/user_plan.py +0 -0
  91. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/repositories/__init__.py +0 -0
  92. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/repositories/plan_repo.py +0 -0
  93. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/repositories/usage_repo.py +0 -0
  94. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/services/__init__.py +0 -0
  95. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/plans/uow.py +0 -0
  96. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/__init__.py +0 -0
  97. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/cache/__init__.py +0 -0
  98. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/cache/permission_cache.py +0 -0
  99. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/dependencies.py +0 -0
  100. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/domain/__init__.py +0 -0
  101. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/domain/entities.py +0 -0
  102. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/domain/exceptions.py +0 -0
  103. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/models/__init__.py +0 -0
  104. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/models/permission.py +0 -0
  105. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/models/role.py +0 -0
  106. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/models/role_permission.py +0 -0
  107. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/repositories/__init__.py +0 -0
  108. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/repositories/rbac_repo.py +0 -0
  109. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/services/__init__.py +0 -0
  110. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/services/rbac_service.py +0 -0
  111. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/rbac/uow.py +0 -0
  112. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/README.md +0 -0
  113. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/__init__.py +0 -0
  114. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/audit.py +0 -0
  115. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/circuit_breaker.py +0 -0
  116. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/cleanup_scheduler.py +0 -0
  117. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/database.py +0 -0
  118. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/error_formatter.py +0 -0
  119. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/exceptions.py +0 -0
  120. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/graceful_shutdown.py +0 -0
  121. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/health.py +0 -0
  122. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/http_utils.py +0 -0
  123. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/lockout.py +0 -0
  124. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/logging.py +0 -0
  125. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/metrics.py +0 -0
  126. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/models.py +0 -0
  127. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/rate_limiter.py +0 -0
  128. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/request_id.py +0 -0
  129. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/schemas.py +0 -0
  130. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/security.py +0 -0
  131. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/state_store.py +0 -0
  132. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/uow.py +0 -0
  133. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/src/identity_plan_kit/shared/uuid7.py +0 -0
  134. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/__init__.py +0 -0
  135. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/auth/__init__.py +0 -0
  136. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/auth/test_auth_service.py +0 -0
  137. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/auth/test_circuit_breaker.py +0 -0
  138. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/auth/test_csrf_state.py +0 -0
  139. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/auth/test_dependencies.py +0 -0
  140. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/auth/test_domain_entities.py +0 -0
  141. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/auth/test_http_utils.py +0 -0
  142. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/auth/test_lockout.py +0 -0
  143. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/auth/test_lockout_concurrent.py +0 -0
  144. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/auth/test_oauth_service_resilience.py +0 -0
  145. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/auth/test_security_comprehensive.py +0 -0
  146. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/auth/test_token_security.py +0 -0
  147. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/conftest.py +0 -0
  148. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/__init__.py +0 -0
  149. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/conftest.py +0 -0
  150. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/test_concurrent_limit_merge.py +0 -0
  151. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/test_concurrent_plan_operations.py +0 -0
  152. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/test_concurrent_token_operations.py +0 -0
  153. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/test_concurrent_user_creation.py +0 -0
  154. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/test_concurrent_user_deactivation.py +0 -0
  155. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/test_limit_boundaries.py +0 -0
  156. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/test_plan_cache_race.py +0 -0
  157. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/test_quota_concurrency.py +0 -0
  158. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/test_token_cleanup.py +0 -0
  159. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/test_token_repository.py +0 -0
  160. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/test_usage_periods.py +0 -0
  161. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/integration/test_user_repository.py +0 -0
  162. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/plans/__init__.py +0 -0
  163. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/plans/test_domain_entities.py +0 -0
  164. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/plans/test_period_edge_cases.py +0 -0
  165. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/plans/test_plan_service.py +0 -0
  166. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/rbac/__init__.py +0 -0
  167. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/rbac/test_permission_cache.py +0 -0
  168. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/rbac/test_permission_cache_concurrent.py +0 -0
  169. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/rbac/test_rbac_cache_stampede.py +0 -0
  170. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/rbac/test_rbac_service.py +0 -0
  171. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/shared/test_audit_pii_masking.py +0 -0
  172. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/shared/test_circuit_breaker_concurrent.py +0 -0
  173. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/shared/test_cleanup_scheduler_concurrent.py +0 -0
  174. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/shared/test_config_validation.py +0 -0
  175. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/shared/test_exception_handlers.py +0 -0
  176. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/shared/test_http_utils_security.py +0 -0
  177. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/shared/test_rate_limiter.py +0 -0
  178. {identity_plan_kit-0.3.0 → identity_plan_kit-0.3.2}/tests/shared/test_rate_limiter_concurrent.py +0 -0
  179. {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
  Metadata-Version: 2.4
2
2
  Name: identity-plan-kit
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Modern FastAPI library for authentication, RBAC, subscription plans, and usage tracking
5
5
  Author-email: harut <harut.avetisyan2002@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "identity-plan-kit"
3
- version = "0.3.0"
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.0"
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
@@ -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")
@@ -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
- raise HTTPException(
65
- status_code=status.HTTP_402_PAYMENT_REQUIRED,
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
- raise HTTPException(
161
- status_code=status.HTTP_402_PAYMENT_REQUIRED,
162
- detail="No active subscription plan",
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 individually after being fetched for subsequent
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 each plan for future single-plan lookups
1169
- for plan in plans:
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
- return create_error_response(
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()