fastapi-basekit 0.5.3__tar.gz → 0.5.5__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 (149) hide show
  1. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/PKG-INFO +1 -1
  2. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/beanie/repository/base.py +145 -72
  3. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/beanie/service/base.py +39 -3
  4. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlalchemy/repository/base.py +34 -9
  5. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlalchemy/service/base.py +10 -3
  6. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit.egg-info/PKG-INFO +1 -1
  7. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit.egg-info/SOURCES.txt +2 -0
  8. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/pyproject.toml +1 -1
  9. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_beanie_aggregation_hooks.py +5 -3
  10. fastapi_basekit-0.5.5/tests/test_beanie_search_regex.py +53 -0
  11. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_beanie_service_regression.py +73 -0
  12. fastapi_basekit-0.5.5/tests/test_owner_filter_helper.py +65 -0
  13. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_sqlalchemy_service_regression.py +73 -0
  14. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/LICENSE +0 -0
  15. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/README.md +0 -0
  16. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/__init__.py +0 -0
  17. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/__init__.py +0 -0
  18. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/beanie/__init__.py +0 -0
  19. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/beanie/controller/__init__.py +0 -0
  20. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/beanie/controller/base.py +0 -0
  21. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/beanie/repository/__init__.py +0 -0
  22. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/beanie/service/__init__.py +0 -0
  23. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/controller/__init__.py +0 -0
  24. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/controller/base.py +0 -0
  25. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/permissions/__init__.py +0 -0
  26. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/permissions/base.py +0 -0
  27. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlalchemy/__init__.py +0 -0
  28. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlalchemy/controller/__init__.py +0 -0
  29. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlalchemy/controller/base.py +0 -0
  30. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlalchemy/repository/__init__.py +0 -0
  31. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlalchemy/service/__init__.py +0 -0
  32. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlalchemy/session.py +0 -0
  33. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlmodel/__init__.py +0 -0
  34. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlmodel/controller/__init__.py +0 -0
  35. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlmodel/controller/base.py +0 -0
  36. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlmodel/repository/__init__.py +0 -0
  37. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlmodel/repository/base.py +0 -0
  38. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlmodel/service/__init__.py +0 -0
  39. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/aio/sqlmodel/service/base.py +0 -0
  40. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/cli/__init__.py +0 -0
  41. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/cli/main.py +0 -0
  42. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/exceptions/__init__.py +0 -0
  43. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/exceptions/api_exceptions.py +0 -0
  44. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/exceptions/domain.py +0 -0
  45. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/exceptions/handler.py +0 -0
  46. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/openapi.py +0 -0
  47. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/py.typed +0 -0
  48. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/schema/__init__.py +0 -0
  49. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/schema/base.py +0 -0
  50. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/schema/jwt.py +0 -0
  51. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/schema/schema.py +0 -0
  52. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/servicios/__init__.py +0 -0
  53. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/servicios/thrid/__init__.py +0 -0
  54. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/servicios/thrid/jwt.py +0 -0
  55. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/cookiecutter.json +0 -0
  56. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/hooks/post_gen_project.py +0 -0
  57. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/hooks/pre_gen_project.py +0 -0
  58. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/.env.example +0 -0
  59. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/.gitignore +0 -0
  60. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/Dockerfile +0 -0
  61. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/LICENSE +0 -0
  62. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/Makefile +0 -0
  63. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/README.md +0 -0
  64. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/alembic/env.py +0 -0
  65. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/alembic/script.py.mako +0 -0
  66. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/alembic.ini +0 -0
  67. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/__init__.py +0 -0
  68. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/__init__.py +0 -0
  69. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/__init__.py +0 -0
  70. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/__init__.py +0 -0
  71. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/auth/__init__.py +0 -0
  72. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/auth/auth.py +0 -0
  73. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/user/__init__.py +0 -0
  74. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/endpoints/user/user.py +0 -0
  75. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/api/v1/routers.py +0 -0
  76. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/config/__init__.py +0 -0
  77. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/config/database.py +0 -0
  78. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/config/settings.py +0 -0
  79. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/main.py +0 -0
  80. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/middleware/__init__.py +0 -0
  81. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/middleware/auth.py +0 -0
  82. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/middleware/permissions.py +0 -0
  83. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/__init__.py +0 -0
  84. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/auth.py +0 -0
  85. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/base.py +0 -0
  86. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/enums.py +0 -0
  87. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/models/types.py +0 -0
  88. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/permissions/__init__.py +0 -0
  89. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/permissions/user.py +0 -0
  90. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/repositories/__init__.py +0 -0
  91. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/repositories/user/__init__.py +0 -0
  92. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/repositories/user/repository.py +0 -0
  93. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas/__init__.py +0 -0
  94. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas/auth.py +0 -0
  95. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas/base.py +0 -0
  96. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/schemas/user.py +0 -0
  97. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/scripts/__init__.py +0 -0
  98. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/scripts/init.py +0 -0
  99. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/scripts/init_admin.py +0 -0
  100. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services/__init__.py +0 -0
  101. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services/auth_service.py +0 -0
  102. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services/dependency.py +0 -0
  103. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/services/user_service.py +0 -0
  104. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/utils/__init__.py +0 -0
  105. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/utils/exception_handlers.py +0 -0
  106. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/app/utils/security.py +0 -0
  107. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/docker-compose.yml +0 -0
  108. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/pytest.ini +0 -0
  109. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit/templates/project/{{cookiecutter.project_slug}}/requirements.txt +0 -0
  110. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit.egg-info/dependency_links.txt +0 -0
  111. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit.egg-info/entry_points.txt +0 -0
  112. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit.egg-info/requires.txt +0 -0
  113. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/fastapi_basekit.egg-info/top_level.txt +0 -0
  114. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/setup.cfg +0 -0
  115. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_action_param_leak.py +0 -0
  116. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_api_exceptions.py +0 -0
  117. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_base_response.py +0 -0
  118. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_base_service.py +0 -0
  119. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_beanie_aggregation_integration.py +0 -0
  120. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_beanie_keyset_pagination.py +0 -0
  121. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_beanie_repository_regression.py +0 -0
  122. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_beanie_service_mutable_defaults.py +0 -0
  123. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_controller_auto_permissions.py +0 -0
  124. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_crud_beanie_controller.py +0 -0
  125. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_crud_controller.py +0 -0
  126. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_crud_sqlmodel_repository_service.py +0 -0
  127. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_exception_handlers.py +0 -0
  128. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_filter_operators.py +0 -0
  129. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_format_response_schema_override.py +0 -0
  130. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_generics_optional_and_cache.py +0 -0
  131. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_generics_wiring.py +0 -0
  132. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_jwt_config_robustness.py +0 -0
  133. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_jwt_service.py +0 -0
  134. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_openapi_simplify.py +0 -0
  135. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_params_classification.py +0 -0
  136. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_params_coercion_matrix.py +0 -0
  137. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_params_coercion_unit.py +0 -0
  138. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_params_http_matrix_beanie.py +0 -0
  139. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_params_http_matrix_sqlalchemy.py +0 -0
  140. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_params_rich_type_coercion.py +0 -0
  141. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_path_filter_mixin_compat.py +0 -0
  142. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_post_process_list_hook.py +0 -0
  143. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_pulbot_cbv_pattern_beanie.py +0 -0
  144. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_repo_signature_aliases.py +0 -0
  145. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_sql_queryset_override.py +0 -0
  146. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_sqlalchemy_base_service_order.py +0 -0
  147. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_sqlalchemy_repository_regression.py +0 -0
  148. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_sqlmodel_filter_operators.py +0 -0
  149. {fastapi_basekit-0.5.3 → fastapi_basekit-0.5.5}/tests/test_typed_filter_coercion.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fastapi-basekit
3
- Version: 0.5.3
3
+ Version: 0.5.5
4
4
  Summary: Utilities and base classes for FastAPI async projects (Beanie, SQLAlchemy or SQLModel)
5
5
  Author-email: Jerson Moreno <jerson.ml820@hotmail.com>
6
6
  License: MIT
@@ -2,6 +2,8 @@ import logging
2
2
  from typing import Any, Dict, Generic, List, Optional, Type, TypeVar, Union
3
3
  from typing import get_args, get_origin
4
4
  import re
5
+ import unicodedata
6
+ from functools import lru_cache
5
7
 
6
8
  from bson import ObjectId
7
9
  from pydantic import BaseModel
@@ -11,6 +13,35 @@ from beanie.operators import Or, RegEx
11
13
 
12
14
  logger = logging.getLogger(__name__)
13
15
 
16
+
17
+ @lru_cache(maxsize=None)
18
+ def _accent_class(letter: str) -> str:
19
+ """Clase regex que matchea `letter` con y sin tilde/diéresis, y en mayúscula."""
20
+ variants = {letter, letter.upper()}
21
+ for cp in range(0x00C0, 0x0100): # Latin-1: á é í ó ú ü ñ… (mantiene el patrón corto)
22
+ ch = chr(cp)
23
+ if unicodedata.normalize("NFD", ch)[0].lower() == letter:
24
+ variants.add(ch)
25
+ return "[" + "".join(sorted(variants)) + "]"
26
+
27
+
28
+ def build_search_regex(term: str) -> str:
29
+ """Patrón regex para búsqueda de texto (usar con `options="i"`).
30
+
31
+ - Todo lo que escribe el usuario se escapa: `(`, `[`, `*`… son literales y
32
+ no rompen la query ni cambian el significado del patrón.
33
+ - Cada letra ASCII matchea también sus variantes acentuadas: `a` ↔ `á`,
34
+ `n` ↔ `ñ`, `o` ↔ `Ó`. Así "descripcion" encuentra "descripción".
35
+ """
36
+ parts = []
37
+ for ch in term:
38
+ base = unicodedata.normalize("NFD", ch)[0]
39
+ if base.isascii() and base.isalpha():
40
+ parts.append(_accent_class(base.lower()))
41
+ else:
42
+ parts.append(re.escape(ch))
43
+ return ".*" + "".join(parts) + ".*"
44
+
14
45
  #: Tipo del Document Beanie que gestiona el repositorio. Parametrízalo al
15
46
  #: declarar la subclase — ``class UserRepository(BaseRepository[User])`` — para
16
47
  #: que ``get_by_id``/``create``/``update``/``list_all`` devuelvan el Document
@@ -119,75 +150,60 @@ class BaseRepository(Generic[ModelT]):
119
150
  kwargs["projection"] = projection
120
151
  return kwargs
121
152
 
122
- def build_filter_query(
123
- self,
124
- search: Optional[str],
125
- search_fields: List[str],
126
- filters: dict = None,
127
- order_by: Optional[List[tuple]] = None,
128
- **kwargs,
129
- ) -> FindMany[Document]:
130
- """Versión personalizada que soporta campos Link."""
131
- exprs = []
132
-
133
- if search and search_fields:
134
- exprs.append(
135
- Or(
136
- *[
137
- RegEx(
138
- getattr(self.model, f),
139
- f".*{search}.*",
140
- options="i",
141
- )
142
- for f in search_fields
143
- ]
144
- )
145
- )
146
-
147
- # Obtener campos del modelo
153
+ def _is_link_field(self, field_name: str) -> bool:
154
+ """Verifica si un campo del modelo es de tipo Link (o Optional[Link])."""
148
155
  model_fields = (
149
156
  self.model.model_fields
150
157
  if hasattr(self.model, "model_fields")
151
158
  else {}
152
159
  )
160
+ field_info = model_fields.get(field_name)
161
+ if not field_info:
162
+ return False
153
163
 
154
- def _is_link_field(field_name: str) -> bool:
155
- """Verifica si un campo es de tipo Link."""
156
- field_info = model_fields.get(field_name)
157
- if not field_info:
158
- return False
159
-
160
- field_type = field_info.annotation
161
- origin = get_origin(field_type)
162
-
163
- # Caso directo: Link[Model]
164
- if origin is Link:
165
- return True
166
-
167
- # Caso Optional[Link[Model]] = Union[Link[Model], None]
168
- # O cualquier Union que contenga Link
169
- if origin is not None:
170
- args = get_args(field_type)
171
- for arg in args:
172
- # Verificar si el argumento es Link
173
- arg_origin = get_origin(arg)
174
- if arg_origin is Link:
175
- return True
164
+ field_type = field_info.annotation
165
+ origin = get_origin(field_type)
176
166
 
177
- return False
167
+ # Caso directo: Link[Model]
168
+ if origin is Link:
169
+ return True
178
170
 
179
- raw_filters: Dict[str, Any] = {}
171
+ # Caso Optional[Link[Model]] = Union[Link[Model], None]
172
+ # O cualquier Union que contenga Link
173
+ if origin is not None:
174
+ args = get_args(field_type)
175
+ for arg in args:
176
+ arg_origin = get_origin(arg)
177
+ if arg_origin is Link:
178
+ return True
180
179
 
181
- def _coerce_objectid(value: Any) -> Any:
182
- """str/UUID → ObjectId para queries Mongo sobre Link.id."""
183
- if isinstance(value, ObjectId):
184
- return value
185
- if isinstance(value, str):
186
- try:
187
- return ObjectId(value)
188
- except Exception:
189
- return value
180
+ return False
181
+
182
+ @staticmethod
183
+ def _coerce_objectid(value: Any) -> Any:
184
+ """str/UUID → ObjectId para queries Mongo sobre Link.id."""
185
+ if isinstance(value, ObjectId):
190
186
  return value
187
+ if isinstance(value, str):
188
+ try:
189
+ return ObjectId(value)
190
+ except Exception:
191
+ return value
192
+ return value
193
+
194
+ def _resolve_filter_query_args(self, filters: dict = None) -> list:
195
+ """Traduce un dict de `get_filters()` a los args posicionales que
196
+ acepta `Model.find()`/`Model.find_one()` — resuelve campos Link
197
+ (directos y el alias `<field>_id`), pasa claves Mongo crudas
198
+ (`"user.$id"`, `"$or"`, ...) como dict, y avisa (no falla en
199
+ silencio) si una clave no resuelve a nada del modelo.
200
+
201
+ Compartido por `build_filter_query` (listados) y `get_by_id`
202
+ (retrieve/update/delete escopeados) — mismo motor de resolución de
203
+ filtros para las dos rutas, así un `get_filters()` de seguridad
204
+ protege ambas por igual."""
205
+ exprs: list = []
206
+ raw_filters: Dict[str, Any] = {}
191
207
 
192
208
  for k, v in (filters or {}).items():
193
209
  # MongoDB-style keys (dot-notation like "user.$id" or operators like "$or")
@@ -198,8 +214,8 @@ class BaseRepository(Generic[ModelT]):
198
214
 
199
215
  if hasattr(self.model, k):
200
216
  field_attr = getattr(self.model, k)
201
- if _is_link_field(k):
202
- exprs.append(field_attr.id == _coerce_objectid(v))
217
+ if self._is_link_field(k):
218
+ exprs.append(field_attr.id == self._coerce_objectid(v))
203
219
  else:
204
220
  exprs.append(field_attr == v)
205
221
  continue
@@ -210,32 +226,59 @@ class BaseRepository(Generic[ModelT]):
210
226
  # el modelo y es un Link[X].
211
227
  if k.endswith("_id"):
212
228
  base = k[:-3]
213
- if hasattr(self.model, base) and _is_link_field(base):
229
+ if hasattr(self.model, base) and self._is_link_field(base):
214
230
  field_attr = getattr(self.model, base)
215
- exprs.append(field_attr.id == _coerce_objectid(v))
231
+ exprs.append(field_attr.id == self._coerce_objectid(v))
216
232
  continue
217
233
 
218
234
  # Clave no resoluble (typo, campo inexistente, nesting mal escrito).
219
235
  # Se descarta — pero se AVISA: si la clave venía de `get_filters`
220
236
  # (scoping por tenant/owner), su desaparición silenciosa deja el
221
- # listado sin filtrar → fuga cross-tenant (IDOR). El warning hace
222
- # visible la pérdida en logs/Sentry en vez de fallar en silencio.
237
+ # listado/retrieve SIN ese filtro. El warning hace visible la
238
+ # pérdida en logs/Sentry en vez de fallar en silencio.
223
239
  logger.warning(
224
- "build_filter_query: filtro '%s' descartado (no resuelve a "
225
- "ningún campo de %s). Si era un filtro de scoping, el listado "
226
- "queda SIN ese filtro.",
240
+ "_resolve_filter_query_args: filtro '%s' descartado (no "
241
+ "resuelve a ningún campo de %s). Si era un filtro de "
242
+ "scoping, la query queda SIN ese filtro.",
227
243
  k,
228
244
  getattr(self.model, "__name__", self.model),
229
245
  )
230
246
 
231
247
  # Raw MongoDB filters go first so Beanie processes them as a dict condition
232
- query_args: list = ([raw_filters] if raw_filters else []) + exprs
248
+ return ([raw_filters] if raw_filters else []) + exprs
249
+
250
+ def build_filter_query(
251
+ self,
252
+ search: Optional[str],
253
+ search_fields: List[str],
254
+ filters: dict = None,
255
+ order_by: Optional[List[tuple]] = None,
256
+ **kwargs,
257
+ ) -> FindMany[Document]:
258
+ """Versión personalizada que soporta campos Link."""
259
+ exprs = []
260
+
261
+ if search and search_fields:
262
+ exprs.append(
263
+ Or(
264
+ *[
265
+ RegEx(
266
+ getattr(self.model, f),
267
+ build_search_regex(search),
268
+ options="i",
269
+ )
270
+ for f in search_fields
271
+ ]
272
+ )
273
+ )
274
+
275
+ query_args: list = exprs + self._resolve_filter_query_args(filters)
233
276
  query = self.model.find(*query_args, **self._get_query_kwargs(**kwargs))
234
-
277
+
235
278
  # Apply ordering if provided
236
279
  if order_by:
237
280
  query = query.sort(order_by)
238
-
281
+
239
282
  return query
240
283
 
241
284
  async def paginate(
@@ -340,7 +383,7 @@ class BaseRepository(Generic[ModelT]):
340
383
 
341
384
  if search and search_fields:
342
385
  search_conditions = [
343
- {field: {"$regex": f".*{search}.*", "$options": "i"}}
386
+ {field: {"$regex": build_search_regex(search), "$options": "i"}}
344
387
  for field in search_fields
345
388
  ]
346
389
  if search_conditions:
@@ -521,10 +564,40 @@ class BaseRepository(Generic[ModelT]):
521
564
  async def get_by_id(
522
565
  self,
523
566
  obj_id: Union[str, ObjectId],
567
+ filters: Optional[Dict[str, Any]] = None,
524
568
  **kwargs,
525
569
  ) -> Optional[ModelT]:
570
+ """`filters` (opcional) — scoping de seguridad (típicamente
571
+ `Service.get_filters()`, ej. `{"user.$id": request.state.user.id}`).
572
+ Sin `filters`, comportamiento idéntico a antes (busca por id, sin
573
+ scope) — 100% retrocompatible. Con `filters`, un id que existe pero
574
+ no matchea el scope devuelve `None` (mismo resultado que "no
575
+ encontrado"), cerrando la clase de IDOR de "conozco el id de otro
576
+ tenant y lo pido directo".
577
+
578
+ El chequeo de scope corre SIEMPRE con `fetch_links=False` (find_one
579
+ plano, sobre el documento crudo — DBRef sin resolver), sea cual sea
580
+ el `fetch_links` que pida el caller para el fetch final. Motivo:
581
+ Beanie reescribe `find(..., fetch_links=True)` a una aggregation con
582
+ `$lookup`, y un filtro con notación de punto (`"user.$id"`,
583
+ `"user._id"`, etc.) puede matchear distinto — o no matchear nada —
584
+ según si corre ANTES o DESPUÉS del `$lookup` (mismo bug ya
585
+ documentado en `find_all_production_for_user` de pulbot-backend:
586
+ "fetch_links=True puede descartar resultados silenciosamente al
587
+ filtrar por campos dict anidados"). Combinar scope-filter +
588
+ fetch_links en una sola query es frágil; separarlos lo hace
589
+ determinístico sin importar qué `get_filters()` use cada service."""
526
590
  if not isinstance(obj_id, ObjectId):
527
591
  obj_id = ObjectId(obj_id)
592
+
593
+ if filters:
594
+ scope_exprs = self._resolve_filter_query_args(filters)
595
+ scoped = await self.model.find_one(
596
+ self.model.id == obj_id, *scope_exprs, fetch_links=False
597
+ )
598
+ if not scoped:
599
+ return None
600
+
528
601
  return await self.model.find_one(
529
602
  self.model.id == obj_id,
530
603
  **self._get_query_kwargs(**kwargs),
@@ -82,9 +82,43 @@ class BaseService(Generic[ModelT]):
82
82
  filters = filters or {}
83
83
  return filters
84
84
 
85
+ def owner_filter(self, field: str, value: Any) -> Dict[str, Any]:
86
+ """Filtro de scoping por un campo `Link` (ej. ownership por
87
+ `user`), correcto tanto en `list()` como en `retrieve`/`update`/
88
+ `delete` — sin que el service tenga que conocer por qué la clave
89
+ Mongo cambia entre esos dos caminos.
90
+
91
+ Por qué existen dos claves para "lo mismo": un campo `Link[User]`
92
+ se guarda como DBRef (`{"$ref": "users", "$id": ObjectId(...)}`).
93
+ - `retrieve`/`update`/`delete` van por `get_by_id`, que SIEMPRE
94
+ resuelve el scope con `fetch_links=False` (`find_one` plano sobre
95
+ el documento crudo) — ahí el DBRef está intacto, la clave correcta
96
+ es `"<field>.$id"`.
97
+ - `list()` (y cualquier action cuyo `get_kwargs_query()` pida
98
+ `fetch_links=True`) corre `Model.find(..., fetch_links=True)`,
99
+ que Beanie reescribe a una aggregation con `$lookup` — ahí el
100
+ `$lookup` REEMPLAZA el DBRef por el subdocumento resuelto, así
101
+ que la clave correcta pasa a ser `"<field>._id"`.
102
+ Usar la clave equivocada no tira error — devuelve 0 resultados en
103
+ silencio (bug real ya encontrado usando esto a mano en
104
+ `ToolService` de pulbot-backend, 2026-08-11). Este helper decide
105
+ por vos: usalo en vez de escribir `"<field>.$id"`/`"<field>._id"`
106
+ directo en tu `get_filters()`.
107
+
108
+ ⚠️ Asume que tu action de lectura-por-id (`retrieve`/`update`/
109
+ `delete`) no fuerza `fetch_links=True` en el PROPIO chequeo de
110
+ scope — así es como quedó `get_by_id` del repo (siempre
111
+ `fetch_links=False` para el scope-check, independientemente del
112
+ `fetch_links` que pidas para el fetch final). Si tu repo está
113
+ parcheado para saltarse eso, este helper no aplica."""
114
+ suffix = "._id" if self.action == "list" else ".$id"
115
+ return {f"{field}{suffix}": value}
116
+
85
117
  async def retrieve(self, id: str) -> ModelT:
86
118
  kwargs = self.get_kwargs_query()
87
- obj = await self.repository.get_by_id(id, **kwargs)
119
+ obj = await self.repository.get_by_id(
120
+ id, filters=self.get_filters(), **kwargs
121
+ )
88
122
  if not obj:
89
123
  raise NotFoundException(f"id={id} no encontrado")
90
124
  return obj
@@ -241,7 +275,9 @@ class BaseService(Generic[ModelT]):
241
275
 
242
276
  async def update(self, id: str, data: BaseModel) -> ModelT:
243
277
  kwargs = self.get_kwargs_query()
244
- obj = await self.repository.get_by_id(id, **kwargs)
278
+ obj = await self.repository.get_by_id(
279
+ id, filters=self.get_filters(), **kwargs
280
+ )
245
281
  if not obj:
246
282
  raise NotFoundException(f"id={id} no encontrado")
247
283
  if isinstance(data, BaseModel):
@@ -250,7 +286,7 @@ class BaseService(Generic[ModelT]):
250
286
  return updated
251
287
 
252
288
  async def delete(self, id: str) -> str:
253
- obj = await self.repository.get_by_id(id)
289
+ obj = await self.repository.get_by_id(id, filters=self.get_filters())
254
290
  if not obj:
255
291
  raise NotFoundException(f"id={id} no encontrado")
256
292
  await self.repository.delete(obj)
@@ -487,16 +487,37 @@ class BaseRepository(Generic[ModelT]):
487
487
 
488
488
  return record
489
489
 
490
- async def get(self, record_id: Union[str, UUID]) -> Optional[ModelT]:
491
- """Obtiene un registro por su ID."""
490
+ async def get(
491
+ self,
492
+ record_id: Union[str, UUID],
493
+ filters: Optional[Dict[str, Any]] = None,
494
+ ) -> Optional[ModelT]:
495
+ """Obtiene un registro por su ID.
496
+
497
+ `filters` (opcional) — scoping de seguridad (típicamente
498
+ `Service.get_filters()`). Sin `filters`, usa `session.get()` (fast
499
+ path por PK) — comportamiento idéntico a antes, 100% retrocompatible.
500
+ Con `filters`, cae a un `SELECT ... WHERE id = :id AND <filtros>` —
501
+ un id que existe pero no matchea el scope devuelve `None` (mismo
502
+ resultado que "no encontrado"), cerrando la clase de IDOR de "conozco
503
+ el id de otro tenant y lo pido directo"."""
492
504
  if not self.model:
493
505
  raise ValueError("El modelo no está definido en el repositorio")
494
- return await self.session.get(self.model, record_id)
506
+ if not filters:
507
+ return await self.session.get(self.model, record_id)
508
+ conditions = [self.model.id == record_id] + self._build_conditions(
509
+ filters=filters
510
+ )
511
+ return await self._get_one(conditions=conditions)
495
512
 
496
- async def get_by_id(self, record_id: Union[str, UUID]) -> Optional[ModelT]:
513
+ async def get_by_id(
514
+ self,
515
+ record_id: Union[str, UUID],
516
+ filters: Optional[Dict[str, Any]] = None,
517
+ ) -> Optional[ModelT]:
497
518
  """Alias de `get(id)` — nombre unificado con el repo Beanie
498
519
  (`get_by_id`) para que las lecturas por id sean portables entre ORMs."""
499
- return await self.get(record_id)
520
+ return await self.get(record_id, filters=filters)
500
521
 
501
522
  async def get_by_field(
502
523
  self, field_name: str, value: Any
@@ -509,11 +530,15 @@ class BaseRepository(Generic[ModelT]):
509
530
  self,
510
531
  record_id: Union[str, UUID],
511
532
  joins: Optional[List[str]] = None,
533
+ filters: Optional[Dict[str, Any]] = None,
512
534
  ) -> Optional[ModelT]:
513
- """Obtiene un registro por ID y carga relaciones dinámicamente."""
514
- return await self._get_one(
515
- conditions=[self.model.id == record_id], joins=joins
516
- )
535
+ """Obtiene un registro por ID y carga relaciones dinámicamente.
536
+
537
+ `filters` (opcional) — mismo scoping de seguridad que `get()`."""
538
+ conditions = [self.model.id == record_id]
539
+ if filters:
540
+ conditions.extend(self._build_conditions(filters=filters))
541
+ return await self._get_one(conditions=conditions, joins=joins)
517
542
 
518
543
  async def get_by_field_with_joins(
519
544
  self,
@@ -122,9 +122,10 @@ class BaseService(Generic[ModelT]):
122
122
  if joins is None:
123
123
  joins = kwargs.get("joins")
124
124
 
125
- obj = await self.repository.get_with_joins(id, joins=joins)
125
+ filters = self.get_filters()
126
+ obj = await self.repository.get_with_joins(id, joins=joins, filters=filters)
126
127
  if not obj:
127
- obj = await self.repository.get(id)
128
+ obj = await self.repository.get(id, filters=filters)
128
129
  if not obj:
129
130
  raise NotFoundException(f"id={id} no encontrado")
130
131
  return obj
@@ -227,6 +228,12 @@ class BaseService(Generic[ModelT]):
227
228
  return created
228
229
 
229
230
  async def update(self, id: str, data: BaseModel | Dict[str, Any]) -> ModelT:
231
+ # Scoping de seguridad ANTES de tocar nada — `repository.update`
232
+ # fetchea por PK cruda internamente (sin `get_filters()`), así que
233
+ # sin este check un id de otro tenant se podía editar directo.
234
+ scoped = await self.repository.get(id, filters=self.get_filters())
235
+ if not scoped:
236
+ raise NotFoundException(f"id={id} no encontrado")
230
237
  update_data = (
231
238
  data.model_dump(exclude_unset=True)
232
239
  if isinstance(data, BaseModel)
@@ -277,7 +284,7 @@ class BaseService(Generic[ModelT]):
277
284
  return True
278
285
 
279
286
  async def delete(self, id: str) -> bool:
280
- obj = await self.repository.get(id)
287
+ obj = await self.repository.get(id, filters=self.get_filters())
281
288
  if not obj:
282
289
  raise NotFoundException(message=f"id={id} no encontrado")
283
290
  return await self.apply_delete(obj)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fastapi-basekit
3
- Version: 0.5.3
3
+ Version: 0.5.5
4
4
  Summary: Utilities and base classes for FastAPI async projects (Beanie, SQLAlchemy or SQLModel)
5
5
  Author-email: Jerson Moreno <jerson.ml820@hotmail.com>
6
6
  License: MIT
@@ -113,6 +113,7 @@ tests/test_beanie_aggregation_hooks.py
113
113
  tests/test_beanie_aggregation_integration.py
114
114
  tests/test_beanie_keyset_pagination.py
115
115
  tests/test_beanie_repository_regression.py
116
+ tests/test_beanie_search_regex.py
116
117
  tests/test_beanie_service_mutable_defaults.py
117
118
  tests/test_beanie_service_regression.py
118
119
  tests/test_controller_auto_permissions.py
@@ -127,6 +128,7 @@ tests/test_generics_wiring.py
127
128
  tests/test_jwt_config_robustness.py
128
129
  tests/test_jwt_service.py
129
130
  tests/test_openapi_simplify.py
131
+ tests/test_owner_filter_helper.py
130
132
  tests/test_params_classification.py
131
133
  tests/test_params_coercion_matrix.py
132
134
  tests/test_params_coercion_unit.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fastapi-basekit"
7
- version = "0.5.3"
7
+ version = "0.5.5"
8
8
  description = "Utilities and base classes for FastAPI async projects (Beanie, SQLAlchemy or SQLModel)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -13,6 +13,8 @@ Covers:
13
13
  from unittest.mock import AsyncMock, MagicMock, patch
14
14
 
15
15
  import pytest
16
+
17
+ from fastapi_basekit.aio.beanie.repository.base import build_search_regex
16
18
  from beanie import Document
17
19
  from pydantic import Field
18
20
 
@@ -97,8 +99,8 @@ class TestBuildListPipeline:
97
99
  assert len(pipeline) == 1
98
100
  match = pipeline[0]["$match"]
99
101
  assert "$or" in match
100
- assert {"name": {"$regex": ".*ana.*", "$options": "i"}} in match["$or"]
101
- assert {"email": {"$regex": ".*ana.*", "$options": "i"}} in match["$or"]
102
+ assert {"name": {"$regex": build_search_regex("ana"), "$options": "i"}} in match["$or"]
103
+ assert {"email": {"$regex": build_search_regex("ana"), "$options": "i"}} in match["$or"]
102
104
 
103
105
  def test_filters_plus_search_combined_with_and(self):
104
106
  repo = FakeRepo()
@@ -110,7 +112,7 @@ class TestBuildListPipeline:
110
112
  match = pipeline[0]["$match"]
111
113
  assert "$and" in match
112
114
  assert {"is_active": True} in match["$and"]
113
- assert {"$or": [{"name": {"$regex": ".*ana.*", "$options": "i"}}]} in match["$and"]
115
+ assert {"$or": [{"name": {"$regex": build_search_regex("ana"), "$options": "i"}}]} in match["$and"]
114
116
 
115
117
  def test_simple_order_by_appends_sort_stage(self):
116
118
  repo = FakeRepo()
@@ -0,0 +1,53 @@
1
+ """build_search_regex: escape del término y búsqueda sin acentos (Beanie)."""
2
+
3
+ import re
4
+
5
+ import pytest
6
+
7
+ from fastapi_basekit.aio.beanie.repository.base import build_search_regex
8
+
9
+
10
+ def _matches(term: str, text: str) -> bool:
11
+ # Mismo significado que Mongo `$regex` con `$options: "i"`.
12
+ return re.search(build_search_regex(term), text, re.IGNORECASE) is not None
13
+
14
+
15
+ @pytest.mark.parametrize(
16
+ "text",
17
+ ["Descripción", "descripcion", "DESCRIPCIÓN", "Descripcíon"],
18
+ )
19
+ def test_sin_tilde_encuentra_con_tilde_y_viceversa(text):
20
+ assert _matches("descripcion", text)
21
+ assert _matches("descripción", text)
22
+
23
+
24
+ def test_enie_y_n_son_equivalentes():
25
+ assert _matches("ano", "Año nuevo")
26
+ assert _matches("año", "ANO")
27
+
28
+
29
+ def test_mayusculas_acentuadas_del_termino():
30
+ assert _matches("ÑANDÚ", "el ñandu corre")
31
+ assert _matches("ñandú", "NANDU")
32
+
33
+
34
+ @pytest.mark.parametrize("term", ["(", "[", "*", "+", "?", "\\", "a.*", "$", "^"])
35
+ def test_caracteres_especiales_son_literales(term):
36
+ # No deben romper la regex ni comportarse como metacaracteres.
37
+ assert re.compile(build_search_regex(term)) # compila
38
+ assert not _matches(term, "xyz")
39
+
40
+
41
+ def test_punto_asterisco_no_es_comodin():
42
+ assert not _matches("a.*", "abc")
43
+ assert _matches("a.*", "zza.*zz")
44
+
45
+
46
+ def test_parentesis_se_busca_literal():
47
+ assert _matches("(beta)", "versión (beta) 2")
48
+ assert not _matches("(beta)", "versión beta 2")
49
+
50
+
51
+ def test_termino_sin_acentos_no_cambia_el_resto_del_texto():
52
+ assert _matches("whats", "WhatsApp")
53
+ assert not _matches("telegram", "WhatsApp")
@@ -242,3 +242,76 @@ async def test_scoped_filter_merges_with_user_filter(seeded):
242
242
  svc_t1 = ScopedItemService(repository=ItemRepo(), tenant="t1")
243
243
  items, total = await svc_t1.list(filters={"active": True}, count=50)
244
244
  assert {i.name for i in items} == {"Alpha"} # t1 + active
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # get_filters scoping en retrieve/update/delete (IDOR — CLAUDE.md prometía
249
+ # esto ["Si necesitas un objeto por id con scope, usa `service.retrieve(id)`"]
250
+ # pero `retrieve`/`update`/`delete` llamaban `repository.get_by_id(id)`
251
+ # directo, bypasseando `get_filters()` igual que un `get_by_id` crudo. Un
252
+ # `ScopedItemService` (tenant t2) podía leer/editar/borrar un Item de t1
253
+ # solo con adivinar/enumerar su id. Fix: `retrieve`/`update`/`delete` ahora
254
+ # pasan `filters=self.get_filters()` a `repository.get_by_id`.
255
+ # ---------------------------------------------------------------------------
256
+
257
+ @pytest.mark.asyncio
258
+ async def test_retrieve_respects_scope_own_tenant_ok(seeded):
259
+ svc_t1 = ScopedItemService(repository=ItemRepo(), tenant="t1")
260
+ got = await svc_t1.retrieve(str(seeded[0].id)) # Alpha, tenant t1
261
+ assert got.name == "Alpha"
262
+
263
+
264
+ @pytest.mark.asyncio
265
+ async def test_retrieve_cross_tenant_id_is_not_found(seeded):
266
+ """El id de Gamma (t2) existe de verdad — pedido desde un service
267
+ scopeado a t1 debe comportarse como si no existiera (404), no leakear
268
+ el registro de otro tenant."""
269
+ svc_t1 = ScopedItemService(repository=ItemRepo(), tenant="t1")
270
+ with pytest.raises(NotFoundException):
271
+ await svc_t1.retrieve(str(seeded[2].id)) # Gamma, tenant t2
272
+
273
+ # El mismo id SÍ resuelve para el tenant dueño.
274
+ svc_t2 = ScopedItemService(repository=ItemRepo(), tenant="t2")
275
+ got = await svc_t2.retrieve(str(seeded[2].id))
276
+ assert got.name == "Gamma"
277
+
278
+
279
+ @pytest.mark.asyncio
280
+ async def test_update_cross_tenant_id_is_not_found_and_not_modified(seeded):
281
+ svc_t1 = ScopedItemService(repository=ItemRepo(), tenant="t1")
282
+ with pytest.raises(NotFoundException):
283
+ await svc_t1.update(str(seeded[2].id), ItemUpdate(qty=999))
284
+
285
+ # No se modificó — lo confirma el dueño real (t2).
286
+ svc_t2 = ScopedItemService(repository=ItemRepo(), tenant="t2")
287
+ unchanged = await svc_t2.retrieve(str(seeded[2].id))
288
+ assert unchanged.qty == 9
289
+
290
+
291
+ @pytest.mark.asyncio
292
+ async def test_delete_cross_tenant_id_is_not_found_and_not_deleted(seeded):
293
+ svc_t1 = ScopedItemService(repository=ItemRepo(), tenant="t1")
294
+ with pytest.raises(NotFoundException):
295
+ await svc_t1.delete(str(seeded[2].id))
296
+
297
+ # Sigue existiendo — lo confirma el dueño real (t2).
298
+ svc_t2 = ScopedItemService(repository=ItemRepo(), tenant="t2")
299
+ still_there = await svc_t2.retrieve(str(seeded[2].id))
300
+ assert still_there.name == "Gamma"
301
+
302
+
303
+ @pytest.mark.asyncio
304
+ async def test_unscoped_service_retrieve_update_delete_unaffected(seeded):
305
+ """Retrocompatibilidad: un service SIN override de `get_filters` (el
306
+ caso común, `get_filters` devuelve `{}`) sigue viendo/editando/borrando
307
+ cualquier id — cero cambio de comportamiento para el 99% de los
308
+ services existentes que no scopean por owner."""
309
+ svc = ItemService(repository=ItemRepo())
310
+ got = await svc.retrieve(str(seeded[2].id)) # Gamma, sin scoping
311
+ assert got.name == "Gamma"
312
+
313
+ updated = await svc.update(str(seeded[2].id), ItemUpdate(qty=42))
314
+ assert updated.qty == 42
315
+
316
+ res = await svc.delete(str(seeded[2].id))
317
+ assert res == "deleted"