From c02e3b145366dc139e158e1b45370f9f08502396 Mon Sep 17 00:00:00 2001 From: Sebastian Kalisz <23085519+KaliszS@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:56:40 +0100 Subject: [PATCH] feat(backend): tests v2 --- .github/workflows/ci.yml | 19 - Makefile | 3 +- backend/README.md | 5 +- backend/_tests/__init__.py | 1 + backend/_tests/api/__init__.py | 1 + backend/_tests/api/v1/__init__.py | 1 + backend/_tests/api/v1/conftest.py | 40 + .../{tests => _tests}/api/v1/test_api_keys.py | 4 +- .../api/v1/test_applications.py | 4 +- backend/{tests => _tests}/api/v1/test_auth.py | 4 +- .../api/v1/test_connections.py | 4 +- .../api/v1/test_dashboard.py | 4 +- .../api/v1/test_external_connectors.py | 2 +- .../api/v1/test_garmin_webhooks.py | 2 +- .../api/v1/test_import_data.py | 4 +- .../{tests => _tests}/api/v1/test_oauth.py | 4 +- .../api/v1/test_sdk_sync_auth.py | 4 +- .../api/v1/test_sdk_token.py | 2 +- .../api/v1/test_summaries.py | 4 +- .../api/v1/test_sync_data.py | 2 +- .../{tests => _tests}/api/v1/test_token.py | 4 +- .../api/v1/test_user_invitation_code.py | 4 +- .../{tests => _tests}/api/v1/test_users.py | 4 +- .../api/v1/test_vendor_workouts.py | 2 +- .../{tests => _tests}/api/v1/test_workouts.py | 4 +- backend/_tests/conftest.py | 263 ++++++ backend/_tests/factories.py | 558 +++++++++++++ backend/_tests/integrations/__init__.py | 1 + .../integrations/test_apple_import.py | 4 +- .../integrations/test_apple_sdk_import.py | 2 +- .../integrations/test_garmin_import.py | 2 +- .../integrations/test_polar_import.py | 4 +- .../integrations/test_provider_oauth.py | 8 +- .../integrations/test_suunto_import.py | 2 +- .../providers}/__init__.py | 0 backend/_tests/providers/apple/__init__.py | 0 .../providers/apple/test_apple_handlers.py | 0 .../providers/apple/test_apple_strategy.py | 0 backend/_tests/providers/conftest.py | 193 +++++ backend/_tests/providers/garmin/__init__.py | 0 .../providers/garmin/test_garmin_247.py | 2 +- .../providers/garmin/test_garmin_backfill.py | 0 .../providers/garmin/test_garmin_oauth.py | 2 +- .../providers/garmin/test_garmin_strategy.py | 0 .../providers/garmin/test_garmin_workouts.py | 2 +- backend/_tests/providers/polar/__init__.py | 0 .../providers/polar/test_polar_oauth.py | 2 +- .../providers/polar/test_polar_strategy.py | 0 .../providers/polar/test_polar_workouts.py | 2 +- backend/_tests/providers/suunto/__init__.py | 0 .../providers/suunto/test_suunto_oauth.py | 2 +- .../providers/suunto/test_suunto_strategy.py | 0 .../providers/suunto/test_suunto_workouts.py | 8 +- .../_tests/providers/templates/__init__.py | 0 .../templates/test_base_templates.py | 0 .../providers/test_base_strategy.py | 0 .../providers/test_provider_factory.py | 0 backend/_tests/repositories/__init__.py | 1 + .../repositories/test_api_key_repository.py | 2 +- .../test_application_repository.py | 2 +- .../test_data_point_series_repository.py | 2 +- .../repositories/test_developer_repository.py | 2 +- .../test_device_type_priority_repository.py | 0 .../test_event_record_detail_repository.py | 2 +- .../test_event_record_repository.py | 2 +- .../test_provider_priority_repository.py | 0 .../test_provider_settings_repository.py | 2 +- .../test_refresh_token_repository.py | 2 +- .../test_user_connection_repository.py | 2 +- .../repositories/test_user_repository.py | 2 +- backend/_tests/schemas/__init__.py | 0 .../schemas/test_provider_name.py | 0 .../schemas/test_schema_validation.py | 0 backend/_tests/services/__init__.py | 1 + .../services/test_api_key_service.py | 2 +- .../services/test_application_service.py | 2 +- .../services/test_developer_service.py | 2 +- .../services/test_event_record_service.py | 2 +- .../services/test_priority_service.py | 0 .../test_provider_settings_service.py | 0 .../services/test_refresh_token_service.py | 2 +- .../services/test_sdk_token_service.py | 0 .../services/test_system_info_service.py | 2 +- .../services/test_time_series_service.py | 2 +- .../services/test_user_connection_service.py | 2 +- .../test_user_invitation_code_service.py | 2 +- .../services/test_user_service.py | 2 +- backend/_tests/tasks/__init__.py | 1 + backend/_tests/tasks/conftest.py | 46 ++ .../tasks/test_finalize_stale_sleep_task.py | 0 .../tasks/test_periodic_sync_task.py | 2 +- .../tasks/test_poll_sqs_task.py | 0 .../tasks/test_process_apple_upload_task.py | 2 +- .../tasks/test_process_upload_task.py | 2 +- .../tasks/test_sync_vendor_data_task.py | 2 +- backend/_tests/utils/__init__.py | 9 + backend/_tests/utils/auth.py | 27 + .../{tests => _tests}/utils_tests/__init__.py | 0 .../utils_tests/test_api_utils.py | 0 .../utils_tests/test_auth_utils.py | 2 +- .../utils_tests/test_conversion.py | 2 +- .../utils_tests/test_dates.py | 0 .../utils_tests/test_exceptions.py | 0 .../utils_tests/test_hateoas.py | 0 .../utils_tests/test_healthcheck.py | 0 .../utils_tests/test_sdk_auth.py | 2 +- .../utils_tests/test_security.py | 0 backend/pyproject.toml | 4 +- backend/tests/__init__.py | 1 - backend/tests/api/__init__.py | 1 - backend/tests/api/v1/__init__.py | 1 - backend/tests/api/v1/conftest.py | 20 +- backend/tests/conftest.py | 268 ++++--- backend/tests/factories.py | 759 ++++++++---------- backend/tests/integrations/__init__.py | 1 - backend/tests/providers/conftest.py | 164 +--- backend/tests/repositories/__init__.py | 1 - backend/tests/services/__init__.py | 1 - backend/tests/tasks/__init__.py | 1 - backend/tests/tasks/conftest.py | 30 +- backend/tests/test_smoke.py | 19 + backend/tests/utils/__init__.py | 9 - backend/tests/utils/auth.py | 6 +- backend/uv.lock | 121 ++- contributing/testing.md | 32 +- 125 files changed, 1872 insertions(+), 895 deletions(-) create mode 100644 backend/_tests/__init__.py create mode 100644 backend/_tests/api/__init__.py create mode 100644 backend/_tests/api/v1/__init__.py create mode 100644 backend/_tests/api/v1/conftest.py rename backend/{tests => _tests}/api/v1/test_api_keys.py (99%) rename backend/{tests => _tests}/api/v1/test_applications.py (98%) rename backend/{tests => _tests}/api/v1/test_auth.py (99%) rename backend/{tests => _tests}/api/v1/test_connections.py (98%) rename backend/{tests => _tests}/api/v1/test_dashboard.py (99%) rename backend/{tests => _tests}/api/v1/test_external_connectors.py (98%) rename backend/{tests => _tests}/api/v1/test_garmin_webhooks.py (99%) rename backend/{tests => _tests}/api/v1/test_import_data.py (96%) rename backend/{tests => _tests}/api/v1/test_oauth.py (99%) rename backend/{tests => _tests}/api/v1/test_sdk_sync_auth.py (97%) rename backend/{tests => _tests}/api/v1/test_sdk_token.py (98%) rename backend/{tests => _tests}/api/v1/test_summaries.py (99%) rename backend/{tests => _tests}/api/v1/test_sync_data.py (99%) rename backend/{tests => _tests}/api/v1/test_token.py (98%) rename backend/{tests => _tests}/api/v1/test_user_invitation_code.py (98%) rename backend/{tests => _tests}/api/v1/test_users.py (99%) rename backend/{tests => _tests}/api/v1/test_vendor_workouts.py (99%) rename backend/{tests => _tests}/api/v1/test_workouts.py (99%) create mode 100644 backend/_tests/conftest.py create mode 100644 backend/_tests/factories.py create mode 100644 backend/_tests/integrations/__init__.py rename backend/{tests => _tests}/integrations/test_apple_import.py (94%) rename backend/{tests => _tests}/integrations/test_apple_sdk_import.py (99%) rename backend/{tests => _tests}/integrations/test_garmin_import.py (99%) rename backend/{tests => _tests}/integrations/test_polar_import.py (98%) rename backend/{tests => _tests}/integrations/test_provider_oauth.py (97%) rename backend/{tests => _tests}/integrations/test_suunto_import.py (99%) rename backend/{tests/providers/templates => _tests/providers}/__init__.py (100%) create mode 100644 backend/_tests/providers/apple/__init__.py rename backend/{tests => _tests}/providers/apple/test_apple_handlers.py (100%) rename backend/{tests => _tests}/providers/apple/test_apple_strategy.py (100%) create mode 100644 backend/_tests/providers/conftest.py create mode 100644 backend/_tests/providers/garmin/__init__.py rename backend/{tests => _tests}/providers/garmin/test_garmin_247.py (99%) rename backend/{tests => _tests}/providers/garmin/test_garmin_backfill.py (100%) rename backend/{tests => _tests}/providers/garmin/test_garmin_oauth.py (99%) rename backend/{tests => _tests}/providers/garmin/test_garmin_strategy.py (100%) rename backend/{tests => _tests}/providers/garmin/test_garmin_workouts.py (99%) create mode 100644 backend/_tests/providers/polar/__init__.py rename backend/{tests => _tests}/providers/polar/test_polar_oauth.py (99%) rename backend/{tests => _tests}/providers/polar/test_polar_strategy.py (100%) rename backend/{tests => _tests}/providers/polar/test_polar_workouts.py (99%) create mode 100644 backend/_tests/providers/suunto/__init__.py rename backend/{tests => _tests}/providers/suunto/test_suunto_oauth.py (99%) rename backend/{tests => _tests}/providers/suunto/test_suunto_strategy.py (100%) rename backend/{tests => _tests}/providers/suunto/test_suunto_workouts.py (98%) create mode 100644 backend/_tests/providers/templates/__init__.py rename backend/{tests => _tests}/providers/templates/test_base_templates.py (100%) rename backend/{tests => _tests}/providers/test_base_strategy.py (100%) rename backend/{tests => _tests}/providers/test_provider_factory.py (100%) create mode 100644 backend/_tests/repositories/__init__.py rename backend/{tests => _tests}/repositories/test_api_key_repository.py (99%) rename backend/{tests => _tests}/repositories/test_application_repository.py (98%) rename backend/{tests => _tests}/repositories/test_data_point_series_repository.py (99%) rename backend/{tests => _tests}/repositories/test_developer_repository.py (99%) rename backend/{tests => _tests}/repositories/test_device_type_priority_repository.py (100%) rename backend/{tests => _tests}/repositories/test_event_record_detail_repository.py (99%) rename backend/{tests => _tests}/repositories/test_event_record_repository.py (99%) rename backend/{tests => _tests}/repositories/test_provider_priority_repository.py (100%) rename backend/{tests => _tests}/repositories/test_provider_settings_repository.py (99%) rename backend/{tests => _tests}/repositories/test_refresh_token_repository.py (99%) rename backend/{tests => _tests}/repositories/test_user_connection_repository.py (99%) rename backend/{tests => _tests}/repositories/test_user_repository.py (99%) create mode 100644 backend/_tests/schemas/__init__.py rename backend/{tests => _tests}/schemas/test_provider_name.py (100%) rename backend/{tests => _tests}/schemas/test_schema_validation.py (100%) create mode 100644 backend/_tests/services/__init__.py rename backend/{tests => _tests}/services/test_api_key_service.py (99%) rename backend/{tests => _tests}/services/test_application_service.py (99%) rename backend/{tests => _tests}/services/test_developer_service.py (99%) rename backend/{tests => _tests}/services/test_event_record_service.py (99%) rename backend/{tests => _tests}/services/test_priority_service.py (100%) rename backend/{tests => _tests}/services/test_provider_settings_service.py (100%) rename backend/{tests => _tests}/services/test_refresh_token_service.py (99%) rename backend/{tests => _tests}/services/test_sdk_token_service.py (100%) rename backend/{tests => _tests}/services/test_system_info_service.py (99%) rename backend/{tests => _tests}/services/test_time_series_service.py (99%) rename backend/{tests => _tests}/services/test_user_connection_service.py (99%) rename backend/{tests => _tests}/services/test_user_invitation_code_service.py (99%) rename backend/{tests => _tests}/services/test_user_service.py (99%) create mode 100644 backend/_tests/tasks/__init__.py create mode 100644 backend/_tests/tasks/conftest.py rename backend/{tests => _tests}/tasks/test_finalize_stale_sleep_task.py (100%) rename backend/{tests => _tests}/tasks/test_periodic_sync_task.py (99%) rename backend/{tests => _tests}/tasks/test_poll_sqs_task.py (100%) rename backend/{tests => _tests}/tasks/test_process_apple_upload_task.py (99%) rename backend/{tests => _tests}/tasks/test_process_upload_task.py (99%) rename backend/{tests => _tests}/tasks/test_sync_vendor_data_task.py (99%) create mode 100644 backend/_tests/utils/__init__.py create mode 100644 backend/_tests/utils/auth.py rename backend/{tests => _tests}/utils_tests/__init__.py (100%) rename backend/{tests => _tests}/utils_tests/test_api_utils.py (100%) rename backend/{tests => _tests}/utils_tests/test_auth_utils.py (99%) rename backend/{tests => _tests}/utils_tests/test_conversion.py (99%) rename backend/{tests => _tests}/utils_tests/test_dates.py (100%) rename backend/{tests => _tests}/utils_tests/test_exceptions.py (100%) rename backend/{tests => _tests}/utils_tests/test_hateoas.py (100%) rename backend/{tests => _tests}/utils_tests/test_healthcheck.py (100%) rename backend/{tests => _tests}/utils_tests/test_sdk_auth.py (98%) rename backend/{tests => _tests}/utils_tests/test_security.py (100%) create mode 100644 backend/tests/test_smoke.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fb3468c..82b87cf1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,27 +82,8 @@ jobs: needs: changes if: ${{ needs.changes.outputs.backend == 'true' }} runs-on: ubuntu-latest - services: - postgres: - image: postgres:16 - env: - POSTGRES_USER: open-wearables - POSTGRES_PASSWORD: open-wearables - POSTGRES_DB: open_wearables_test - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - redis: - image: redis:7 - ports: - - 6379:6379 env: ENV: test - TEST_DATABASE_URL: postgresql+psycopg://open-wearables:open-wearables@localhost:5432/open_wearables_test SECRET_KEY: test-secret-key-for-ci MASTER_KEY: dGVzdC1tYXN0ZXIta2V5LWZvci10ZXN0aW5nLW9ubHk= steps: diff --git a/Makefile b/Makefile index d91ebbc4..fbdff7d7 100644 --- a/Makefile +++ b/Makefile @@ -26,8 +26,7 @@ stop: ## Stops running instance down: ## Kills running instance $(DOCKER_COMMAND) down -test: ## Run the tests. - export ENV=backend/config/.env.test && \ +test: ## Run the tests cd backend && uv run pytest -v --cov=app migrate: ## Apply all migrations diff --git a/backend/README.md b/backend/README.md index ab4f6f30..48a4d96b 100644 --- a/backend/README.md +++ b/backend/README.md @@ -125,6 +125,9 @@ uv run alembic downgrade -1 ### Running Tests +Tests use **testcontainers** — a disposable PostgreSQL container is created +automatically. Just make sure Docker is running. + ```bash # Run all tests uv run pytest @@ -133,7 +136,7 @@ uv run pytest uv run pytest --cov=app --cov-report=html # Run specific test file -uv run pytest tests/path/to/test_file.py +uv run pytest tests/api/v1/test_users.py ``` ### Code Quality diff --git a/backend/_tests/__init__.py b/backend/_tests/__init__.py new file mode 100644 index 00000000..f73e12cd --- /dev/null +++ b/backend/_tests/__init__.py @@ -0,0 +1 @@ +# Tests package for Open Wearables backend diff --git a/backend/_tests/api/__init__.py b/backend/_tests/api/__init__.py new file mode 100644 index 00000000..42b18b86 --- /dev/null +++ b/backend/_tests/api/__init__.py @@ -0,0 +1 @@ +# API tests package diff --git a/backend/_tests/api/v1/__init__.py b/backend/_tests/api/v1/__init__.py new file mode 100644 index 00000000..395961b2 --- /dev/null +++ b/backend/_tests/api/v1/__init__.py @@ -0,0 +1 @@ +# API v1 tests package diff --git a/backend/_tests/api/v1/conftest.py b/backend/_tests/api/v1/conftest.py new file mode 100644 index 00000000..81b952e2 --- /dev/null +++ b/backend/_tests/api/v1/conftest.py @@ -0,0 +1,40 @@ +""" +API v1 specific fixtures. +""" + +import pytest +from sqlalchemy.orm import Session + +from app.models import ApiKey, Developer, User +from _tests.factories import ApiKeyFactory, DeveloperFactory, UserFactory +from _tests.utils import api_key_headers, developer_auth_headers + + +@pytest.fixture +def developer(db: Session) -> Developer: + """Create a test developer for authentication.""" + return DeveloperFactory(email="test@example.com", password="test_password") + + +@pytest.fixture +def api_key(db: Session, developer: Developer) -> ApiKey: + """Create a test API key.""" + return ApiKeyFactory(developer=developer, name="Test API Key") + + +@pytest.fixture +def user(db: Session) -> User: + """Create a test user.""" + return UserFactory() + + +@pytest.fixture +def auth_headers(developer: Developer) -> dict[str, str]: + """Get authentication headers for the test developer.""" + return developer_auth_headers(developer.id) + + +@pytest.fixture +def api_key_header(api_key: ApiKey) -> dict[str, str]: + """Get API key headers.""" + return api_key_headers(api_key.id) diff --git a/backend/tests/api/v1/test_api_keys.py b/backend/_tests/api/v1/test_api_keys.py similarity index 99% rename from backend/tests/api/v1/test_api_keys.py rename to backend/_tests/api/v1/test_api_keys.py index bd71b959..a51eb273 100644 --- a/backend/tests/api/v1/test_api_keys.py +++ b/backend/_tests/api/v1/test_api_keys.py @@ -12,8 +12,8 @@ from fastapi.testclient import TestClient from sqlalchemy.orm import Session -from tests.factories import ApiKeyFactory, DeveloperFactory -from tests.utils import developer_auth_headers +from _tests.factories import ApiKeyFactory, DeveloperFactory +from _tests.utils import developer_auth_headers class TestListApiKeys: diff --git a/backend/tests/api/v1/test_applications.py b/backend/_tests/api/v1/test_applications.py similarity index 98% rename from backend/tests/api/v1/test_applications.py rename to backend/_tests/api/v1/test_applications.py index 59c1eb28..7147c288 100644 --- a/backend/tests/api/v1/test_applications.py +++ b/backend/_tests/api/v1/test_applications.py @@ -3,8 +3,8 @@ from sqlalchemy.orm import Session from starlette.testclient import TestClient -from tests.factories import ApplicationFactory, DeveloperFactory -from tests.utils import developer_auth_headers +from _tests.factories import ApplicationFactory, DeveloperFactory +from _tests.utils import developer_auth_headers class TestListApplications: diff --git a/backend/tests/api/v1/test_auth.py b/backend/_tests/api/v1/test_auth.py similarity index 99% rename from backend/tests/api/v1/test_auth.py rename to backend/_tests/api/v1/test_auth.py index 6e9eb199..9b1f3f99 100644 --- a/backend/tests/api/v1/test_auth.py +++ b/backend/_tests/api/v1/test_auth.py @@ -15,8 +15,8 @@ from sqlalchemy.orm import Session from app.config import settings -from tests.factories import DeveloperFactory -from tests.utils import developer_auth_headers +from _tests.factories import DeveloperFactory +from _tests.utils import developer_auth_headers class TestLogin: diff --git a/backend/tests/api/v1/test_connections.py b/backend/_tests/api/v1/test_connections.py similarity index 98% rename from backend/tests/api/v1/test_connections.py rename to backend/_tests/api/v1/test_connections.py index f458ef30..ea9e362b 100644 --- a/backend/tests/api/v1/test_connections.py +++ b/backend/_tests/api/v1/test_connections.py @@ -12,8 +12,8 @@ from sqlalchemy.orm import Session from app.schemas.oauth import ConnectionStatus -from tests.factories import ApiKeyFactory, UserConnectionFactory, UserFactory -from tests.utils import api_key_headers +from _tests.factories import ApiKeyFactory, UserConnectionFactory, UserFactory +from _tests.utils import api_key_headers class TestConnectionsEndpoints: diff --git a/backend/tests/api/v1/test_dashboard.py b/backend/_tests/api/v1/test_dashboard.py similarity index 99% rename from backend/tests/api/v1/test_dashboard.py rename to backend/_tests/api/v1/test_dashboard.py index 7beca033..d4974719 100644 --- a/backend/tests/api/v1/test_dashboard.py +++ b/backend/_tests/api/v1/test_dashboard.py @@ -8,7 +8,7 @@ from fastapi.testclient import TestClient from sqlalchemy.orm import Session -from tests.factories import ( +from _tests.factories import ( DataPointSeriesFactory, DataSourceFactory, DeveloperFactory, @@ -17,7 +17,7 @@ UserConnectionFactory, UserFactory, ) -from tests.utils import developer_auth_headers +from _tests.utils import developer_auth_headers class TestGetDashboardStats: diff --git a/backend/tests/api/v1/test_external_connectors.py b/backend/_tests/api/v1/test_external_connectors.py similarity index 98% rename from backend/tests/api/v1/test_external_connectors.py rename to backend/_tests/api/v1/test_external_connectors.py index 2960bf37..06e7e96c 100644 --- a/backend/tests/api/v1/test_external_connectors.py +++ b/backend/_tests/api/v1/test_external_connectors.py @@ -8,7 +8,7 @@ from starlette.testclient import TestClient from app.services.sdk_token_service import create_sdk_user_token -from tests.factories import ApiKeyFactory +from _tests.factories import ApiKeyFactory @pytest.fixture(autouse=True) diff --git a/backend/tests/api/v1/test_garmin_webhooks.py b/backend/_tests/api/v1/test_garmin_webhooks.py similarity index 99% rename from backend/tests/api/v1/test_garmin_webhooks.py rename to backend/_tests/api/v1/test_garmin_webhooks.py index 3d43bdda..83723caa 100644 --- a/backend/tests/api/v1/test_garmin_webhooks.py +++ b/backend/_tests/api/v1/test_garmin_webhooks.py @@ -14,7 +14,7 @@ from fastapi.testclient import TestClient from sqlalchemy.orm import Session -from tests.factories import UserConnectionFactory, UserFactory +from _tests.factories import UserConnectionFactory, UserFactory class TestGarminPingWebhook: diff --git a/backend/tests/api/v1/test_import_data.py b/backend/_tests/api/v1/test_import_data.py similarity index 96% rename from backend/tests/api/v1/test_import_data.py rename to backend/_tests/api/v1/test_import_data.py index 1f44bd65..fdc01fbe 100644 --- a/backend/tests/api/v1/test_import_data.py +++ b/backend/_tests/api/v1/test_import_data.py @@ -9,8 +9,8 @@ from fastapi.testclient import TestClient from sqlalchemy.orm import Session -from tests.factories import ApiKeyFactory, UserFactory -from tests.utils import api_key_headers +from _tests.factories import ApiKeyFactory, UserFactory +from _tests.utils import api_key_headers class TestXMLImportEndpoint: diff --git a/backend/tests/api/v1/test_oauth.py b/backend/_tests/api/v1/test_oauth.py similarity index 99% rename from backend/tests/api/v1/test_oauth.py rename to backend/_tests/api/v1/test_oauth.py index ae952b83..26b34bf5 100644 --- a/backend/tests/api/v1/test_oauth.py +++ b/backend/_tests/api/v1/test_oauth.py @@ -13,8 +13,8 @@ from fastapi.testclient import TestClient from sqlalchemy.orm import Session -from tests.factories import DeveloperFactory -from tests.utils import developer_auth_headers +from _tests.factories import DeveloperFactory +from _tests.utils import developer_auth_headers class TestOAuthAuthorizeEndpoint: diff --git a/backend/tests/api/v1/test_sdk_sync_auth.py b/backend/_tests/api/v1/test_sdk_sync_auth.py similarity index 97% rename from backend/tests/api/v1/test_sdk_sync_auth.py rename to backend/_tests/api/v1/test_sdk_sync_auth.py index a69c83be..5aca7992 100644 --- a/backend/tests/api/v1/test_sdk_sync_auth.py +++ b/backend/_tests/api/v1/test_sdk_sync_auth.py @@ -8,8 +8,8 @@ from starlette.testclient import TestClient from app.services.sdk_token_service import create_sdk_user_token -from tests.factories import ApiKeyFactory, DeveloperFactory -from tests.utils import developer_auth_headers +from _tests.factories import ApiKeyFactory, DeveloperFactory +from _tests.utils import developer_auth_headers @pytest.fixture(autouse=True) diff --git a/backend/tests/api/v1/test_sdk_token.py b/backend/_tests/api/v1/test_sdk_token.py similarity index 98% rename from backend/tests/api/v1/test_sdk_token.py rename to backend/_tests/api/v1/test_sdk_token.py index 50088488..7d9efae2 100644 --- a/backend/tests/api/v1/test_sdk_token.py +++ b/backend/_tests/api/v1/test_sdk_token.py @@ -5,7 +5,7 @@ from starlette.testclient import TestClient from app.config import settings -from tests.factories import ApplicationFactory, DeveloperFactory, UserFactory +from _tests.factories import ApplicationFactory, DeveloperFactory, UserFactory class TestCreateUserToken: diff --git a/backend/tests/api/v1/test_summaries.py b/backend/_tests/api/v1/test_summaries.py similarity index 99% rename from backend/tests/api/v1/test_summaries.py rename to backend/_tests/api/v1/test_summaries.py index 2bd81008..b46d9e18 100644 --- a/backend/tests/api/v1/test_summaries.py +++ b/backend/_tests/api/v1/test_summaries.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import Session from app.schemas.oauth import ProviderName -from tests.factories import ( +from _tests.factories import ( ApiKeyFactory, DataPointSeriesFactory, DataSourceFactory, @@ -18,7 +18,7 @@ UserFactory, WorkoutDetailsFactory, ) -from tests.utils import api_key_headers +from _tests.utils import api_key_headers class TestSleepSummaryEndpoint: diff --git a/backend/tests/api/v1/test_sync_data.py b/backend/_tests/api/v1/test_sync_data.py similarity index 99% rename from backend/tests/api/v1/test_sync_data.py rename to backend/_tests/api/v1/test_sync_data.py index 8705a5af..8d6acdd0 100644 --- a/backend/tests/api/v1/test_sync_data.py +++ b/backend/_tests/api/v1/test_sync_data.py @@ -13,7 +13,7 @@ from sqlalchemy.orm import Session from app.schemas.oauth import ConnectionStatus -from tests.factories import ApiKeyFactory, UserConnectionFactory, UserFactory +from _tests.factories import ApiKeyFactory, UserConnectionFactory, UserFactory class TestSyncDataEndpoint: diff --git a/backend/tests/api/v1/test_token.py b/backend/_tests/api/v1/test_token.py similarity index 98% rename from backend/tests/api/v1/test_token.py rename to backend/_tests/api/v1/test_token.py index 497d41c1..5570f32b 100644 --- a/backend/tests/api/v1/test_token.py +++ b/backend/_tests/api/v1/test_token.py @@ -12,7 +12,7 @@ from app.config import settings from app.services import refresh_token_service -from tests.factories import ApplicationFactory, DeveloperFactory, UserFactory +from _tests.factories import ApplicationFactory, DeveloperFactory, UserFactory class TestRefreshToken: @@ -257,7 +257,7 @@ def test_sdk_token_returns_refresh_token_for_app_credentials( def test_admin_sdk_token_returns_refresh_token(self, client: TestClient, db: Session, api_v1_prefix: str) -> None: """Admin-generated SDK token should return refresh token.""" # Arrange - from tests.utils import developer_auth_headers + from _tests.utils import developer_auth_headers developer = DeveloperFactory() user = UserFactory() # Create user diff --git a/backend/tests/api/v1/test_user_invitation_code.py b/backend/_tests/api/v1/test_user_invitation_code.py similarity index 98% rename from backend/tests/api/v1/test_user_invitation_code.py rename to backend/_tests/api/v1/test_user_invitation_code.py index a1cf9cf0..7b731659 100644 --- a/backend/tests/api/v1/test_user_invitation_code.py +++ b/backend/_tests/api/v1/test_user_invitation_code.py @@ -9,8 +9,8 @@ from app.config import settings from app.models.user_invitation_code import UserInvitationCode -from tests.factories import DeveloperFactory, UserFactory -from tests.utils import developer_auth_headers +from _tests.factories import DeveloperFactory, UserFactory +from _tests.utils import developer_auth_headers class TestGenerateInvitationCode: diff --git a/backend/tests/api/v1/test_users.py b/backend/_tests/api/v1/test_users.py similarity index 99% rename from backend/tests/api/v1/test_users.py rename to backend/_tests/api/v1/test_users.py index 383e8527..e23fbc44 100644 --- a/backend/tests/api/v1/test_users.py +++ b/backend/_tests/api/v1/test_users.py @@ -12,8 +12,8 @@ from fastapi.testclient import TestClient from sqlalchemy.orm import Session -from tests.factories import ApiKeyFactory, DeveloperFactory, UserFactory -from tests.utils import api_key_headers, developer_auth_headers +from _tests.factories import ApiKeyFactory, DeveloperFactory, UserFactory +from _tests.utils import api_key_headers, developer_auth_headers class TestListUsers: diff --git a/backend/tests/api/v1/test_vendor_workouts.py b/backend/_tests/api/v1/test_vendor_workouts.py similarity index 99% rename from backend/tests/api/v1/test_vendor_workouts.py rename to backend/_tests/api/v1/test_vendor_workouts.py index e3e7254a..5c85591a 100644 --- a/backend/tests/api/v1/test_vendor_workouts.py +++ b/backend/_tests/api/v1/test_vendor_workouts.py @@ -14,7 +14,7 @@ from sqlalchemy.orm import Session from app.schemas.oauth import ConnectionStatus -from tests.factories import ApiKeyFactory, UserConnectionFactory, UserFactory +from _tests.factories import ApiKeyFactory, UserConnectionFactory, UserFactory class TestVendorWorkoutsEndpoints: diff --git a/backend/tests/api/v1/test_workouts.py b/backend/_tests/api/v1/test_workouts.py similarity index 99% rename from backend/tests/api/v1/test_workouts.py rename to backend/_tests/api/v1/test_workouts.py index 9293b294..531850fa 100644 --- a/backend/tests/api/v1/test_workouts.py +++ b/backend/_tests/api/v1/test_workouts.py @@ -12,8 +12,8 @@ from fastapi.testclient import TestClient from sqlalchemy.orm import Session -from tests.factories import ApiKeyFactory, DataSourceFactory, EventRecordFactory, UserFactory -from tests.utils import api_key_headers +from _tests.factories import ApiKeyFactory, DataSourceFactory, EventRecordFactory, UserFactory +from _tests.utils import api_key_headers class TestWorkoutsEndpoints: diff --git a/backend/_tests/conftest.py b/backend/_tests/conftest.py new file mode 100644 index 00000000..5b504397 --- /dev/null +++ b/backend/_tests/conftest.py @@ -0,0 +1,263 @@ +""" +Main pytest configuration for Open Wearables backend tests. + +Following patterns from know-how-tests.md: +- PostgreSQL test database with transaction rollback +- Auto-use fixtures for global mocking +- Factory pattern for test data +""" + +import os +from collections.abc import Generator +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, event +from sqlalchemy.orm import Session, sessionmaker + +# Set test environment before importing app modules +os.environ["ENV"] = "test" +os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only" +os.environ["MASTER_KEY"] = "dGVzdC1tYXN0ZXIta2V5LWZvci10ZXN0aW5nLW9ubHk=" # base64 test key + +from app.database import BaseDbModel, _get_db_dependency +from app.main import api + +# Test database URL - uses test PostgreSQL database +TEST_DATABASE_URL = os.environ.get( + "TEST_DATABASE_URL", + "postgresql+psycopg://open-wearables:open-wearables@localhost:5432/open_wearables_test", +) + + +@pytest.fixture(scope="session") +def engine() -> Any: + """Create test database engine and tables.""" + test_engine = create_engine( + TEST_DATABASE_URL, + pool_pre_ping=True, + pool_size=5, + max_overflow=10, + ) + BaseDbModel.metadata.create_all(bind=test_engine) + + # Seed series type definitions (these need to exist for foreign key constraints) + from sqlalchemy.orm import Session as SessionClass + + from app.models import SeriesTypeDefinition + from app.schemas.series_types import SERIES_TYPE_DEFINITIONS + + with SessionClass(bind=test_engine) as session: + for type_id, enum, unit in SERIES_TYPE_DEFINITIONS: + # Skip series types with codes exceeding VARCHAR(32) limit + if len(enum.value) > 32: + continue + existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == type_id).first() + if not existing: + series_type = SeriesTypeDefinition(id=type_id, code=enum.value, unit=unit) + session.add(series_type) + session.commit() + + yield test_engine + BaseDbModel.metadata.drop_all(bind=test_engine) + + +@pytest.fixture(scope="session") +def session_factory(engine: Any) -> Any: + """Create session factory bound to test engine.""" + return sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +@pytest.fixture +def db(engine: Any, session_factory: Any) -> Generator[Session, None, None]: + """ + Create a test database session with transaction rollback. + Each test runs in its own transaction that gets rolled back. + """ + connection = engine.connect() + transaction = connection.begin() + session = session_factory(bind=connection) + + # Begin a nested transaction (savepoint) + nested = connection.begin_nested() + + # If the application code calls commit, restart the savepoint + @event.listens_for(session, "after_transaction_end") + def restart_savepoint(session: Session, transaction: Any) -> None: + nonlocal nested + if not nested.is_active: + nested = connection.begin_nested() + + yield session + + # Rollback everything + session.close() + transaction.rollback() + connection.close() + + +@pytest.fixture(autouse=True) +def set_factory_session(db: Session) -> Generator[None, None, None]: + """Set database session for all factory-boy factories.""" + from _tests import factories + + for name, obj in vars(factories).items(): + if isinstance(obj, type) and hasattr(obj, "_meta") and hasattr(obj._meta, "sqlalchemy_session"): + obj._meta.sqlalchemy_session = db + yield + # Clear session after test + for name, obj in vars(factories).items(): + if isinstance(obj, type) and hasattr(obj, "_meta") and hasattr(obj._meta, "sqlalchemy_session"): + obj._meta.sqlalchemy_session = None + + +@pytest.fixture +def client(db: Session) -> Generator[TestClient, None, None]: + """ + Create a test client with database dependency override. + """ + + def override_get_db() -> Generator[Session, None, None]: + yield db + + api.dependency_overrides[_get_db_dependency] = override_get_db + + with TestClient(api) as test_client: + yield test_client + + api.dependency_overrides.clear() + + +# ============================================================================ +# Auto-use fixtures for global mocking +# ============================================================================ + + +@pytest.fixture(autouse=True) +def mock_redis(monkeypatch: pytest.MonkeyPatch) -> Generator[MagicMock, None, None]: + """Globally mock Redis to prevent connection errors in tests.""" + mock = MagicMock() + mock.lock.return_value.__enter__ = MagicMock(return_value=None) + mock.lock.return_value.__exit__ = MagicMock(return_value=None) + mock.get.return_value = None + mock.set.return_value = True + mock.setex.return_value = True + mock.expire.return_value = True + mock.delete.return_value = True + mock.sadd.return_value = 1 + mock.srem.return_value = 1 + mock.smembers.return_value = set() + + # Return mock for redis.from_url (used by get_redis_client) + # We also need to clear lru_cache of get_redis_client to ensure it picks up the mock + from app.integrations.redis_client import get_redis_client + + get_redis_client.cache_clear() + + with patch("redis.from_url", return_value=mock): + yield mock + + +@pytest.fixture(autouse=True) +def mock_celery_tasks(monkeypatch: pytest.MonkeyPatch) -> Generator[MagicMock, None, None]: + """Mock Celery tasks to run synchronously.""" + # Mock the poll_sqs_task specifically + mock_task = MagicMock() + mock_task.delay.return_value = MagicMock() + mock_task.apply_async.return_value = MagicMock() + + with ( + patch("celery.current_app") as mock_celery, + patch("app.integrations.celery.tasks.poll_sqs_task.poll_sqs_task", mock_task), + patch("app.api.routes.v1.import_xml.poll_sqs_task", mock_task), + # Patch the new finalize_stale_sleeps task that was added in this PR + patch("app.integrations.celery.tasks.process_apple_upload_task.finalize_stale_sleeps", mock_task), + ): + # Configure Celery to use in-memory broker and result backend + # We Mock the conf object to return our test settings + mock_conf = MagicMock() + mock_conf.__getitem__ = lambda s, k: { + "task_always_eager": True, + "task_eager_propagates": True, + "broker_url": "memory://", + "result_backend": "cache+memory://", + }.get(k) + + # When update is called, we don't want to actually connect to Redis + mock_conf.update = MagicMock() + mock_celery.conf = mock_conf + + yield mock_task + + +@pytest.fixture(autouse=True) +def mock_external_apis() -> Generator[dict[str, MagicMock], None, None]: + """Mock external API calls (Garmin, Polar, Suunto, AWS).""" + mocks: dict[str, MagicMock] = {} + + # Configure boto3 S3 mock + mock_s3 = MagicMock() + mock_s3.generate_presigned_url.return_value = "https://test-bucket.s3.amazonaws.com/test-key" + mock_s3.generate_presigned_post.return_value = { + "url": "https://test-bucket.s3.amazonaws.com", + "fields": { + "key": "test-user/raw/test.xml", + "Content-Type": "application/xml", + "policy": "test-policy", + "x-amz-algorithm": "AWS4-HMAC-SHA256", + "x-amz-credential": "test-credential", + "x-amz-date": "20251217T000000Z", + "x-amz-signature": "test-signature", + }, + } + mock_s3.head_bucket.return_value = {} + mock_s3.put_object.return_value = {"ETag": "test-etag"} + + with ( + patch("httpx.AsyncClient") as mock_httpx, + patch("boto3.client", return_value=mock_s3) as mock_boto3, + patch("requests.Session") as mock_requests, + patch("app.services.apple.apple_xml.aws_service.AWS_BUCKET_NAME", "test-bucket"), + patch("app.services.apple.apple_xml.presigned_url_service.AWS_BUCKET_NAME", "test-bucket"), + patch("app.services.apple.apple_xml.aws_service.s3_client", mock_s3), + patch("app.services.apple.apple_xml.presigned_url_service.s3_client", mock_s3), + ): + mocks["httpx"] = mock_httpx + mocks["boto3"] = mock_boto3 + mocks["requests"] = mock_requests + mocks["s3"] = mock_s3 + + yield mocks + + +@pytest.fixture(autouse=True) +def fast_password_hashing(monkeypatch: pytest.MonkeyPatch) -> None: + """Speed up tests by using simple password hashing.""" + import sys + + def simple_hash(password: str) -> str: + return f"hashed_{password}" + + def simple_verify(plain: str, hashed: str) -> bool: + return hashed == f"hashed_{plain}" + + # Patch in the source module + monkeypatch.setattr("app.utils.security.get_password_hash", simple_hash) + monkeypatch.setattr("app.utils.security.verify_password", simple_verify) + # Also patch in modules that import these functions directly (use sys.modules to avoid name shadowing) + if "app.services.developer_service" in sys.modules: + monkeypatch.setattr(sys.modules["app.services.developer_service"], "get_password_hash", simple_hash) + monkeypatch.setattr("app.api.routes.v1.auth.verify_password", simple_verify) + + +# ============================================================================ +# Shared test utilities +# ============================================================================ + + +@pytest.fixture +def api_v1_prefix() -> str: + """Return the API v1 prefix.""" + return "/api/v1" diff --git a/backend/_tests/factories.py b/backend/_tests/factories.py new file mode 100644 index 00000000..dc59a7d8 --- /dev/null +++ b/backend/_tests/factories.py @@ -0,0 +1,558 @@ +""" +Factory-boy factories for creating test data. + +Usage: + from tests.factories import UserFactory, DeveloperFactory + user = UserFactory() # Session set automatically via conftest fixture + developer = DeveloperFactory(email="custom@example.com") +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal +from typing import Any +from uuid import uuid4 + +import factory +from factory import LazyAttribute, LazyFunction, Sequence + +from app.models import ( + ApiKey, + Application, + DataPointSeries, + DataSource, + Developer, + EventRecord, + EventRecordDetail, + PersonalRecord, + ProviderSetting, + SeriesTypeDefinition, + SleepDetails, + User, + UserConnection, + WorkoutDetails, +) +from app.schemas.oauth import ConnectionStatus, ProviderName +from app.utils.security import get_password_hash + + +class BaseFactory(factory.alchemy.SQLAlchemyModelFactory): + """Base factory for all SQLAlchemy models.""" + + class Meta: + abstract = True + sqlalchemy_session = None # Set per-test via conftest fixture + sqlalchemy_session_persistence = "flush" # Don't commit, let test handle rollback + + +class SeriesTypeDefinitionFactory(BaseFactory): + """Factory for SeriesTypeDefinition model. + + Note: heart_rate and other standard series types are seeded at session scope. + Use get_or_create_heart_rate() for tests that need the heart_rate type. + """ + + class Meta: + model = SeriesTypeDefinition + + code = factory.Sequence(lambda n: f"test_series_type_{n}") + unit = "test_unit" + + @classmethod + def get_or_create_heart_rate(cls) -> SeriesTypeDefinition: + """Get the pre-seeded heart_rate series type (ID=1).""" + session = cls._meta.sqlalchemy_session + if session: + existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 1).first() + if existing: + return existing + # Fallback: create new one (shouldn't happen with proper seeding) + return cls(id=1, code="heart_rate", unit="bpm") + + @classmethod + def get_or_create_steps(cls) -> SeriesTypeDefinition: + """Get the pre-seeded steps series type (ID=80).""" + session = cls._meta.sqlalchemy_session + if session: + existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 80).first() + if existing: + return existing + # Fallback: create new one (shouldn't happen with proper seeding) + return cls(id=80, code="steps", unit="count") + + @classmethod + def get_or_create_energy(cls) -> SeriesTypeDefinition: + """Get the pre-seeded energy (active calories) series type (ID=81).""" + session = cls._meta.sqlalchemy_session + if session: + existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 81).first() + if existing: + return existing + return cls(id=81, code="energy", unit="kcal") + + @classmethod + def get_or_create_basal_energy(cls) -> SeriesTypeDefinition: + """Get the pre-seeded basal_energy series type (ID=82).""" + session = cls._meta.sqlalchemy_session + if session: + existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 82).first() + if existing: + return existing + return cls(id=82, code="basal_energy", unit="kcal") + + @classmethod + def get_or_create_distance_walking_running(cls) -> SeriesTypeDefinition: + """Get the pre-seeded distance_walking_running series type (ID=100).""" + session = cls._meta.sqlalchemy_session + if session: + existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 100).first() + if existing: + return existing + return cls(id=100, code="distance_walking_running", unit="meters") + + @classmethod + def get_or_create_flights_climbed(cls) -> SeriesTypeDefinition: + """Get the pre-seeded flights_climbed series type (ID=86).""" + session = cls._meta.sqlalchemy_session + if session: + existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 86).first() + if existing: + return existing + return cls(id=86, code="flights_climbed", unit="count") + + @classmethod + def get_or_create_weight(cls) -> SeriesTypeDefinition: + """Get the pre-seeded weight series type (ID=41).""" + session = cls._meta.sqlalchemy_session + if session: + existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 41).first() + if existing: + return existing + return cls(id=41, code="weight", unit="kg") + + @classmethod + def get_or_create_height(cls) -> SeriesTypeDefinition: + """Get the pre-seeded height series type (ID=40).""" + session = cls._meta.sqlalchemy_session + if session: + existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 40).first() + if existing: + return existing + return cls(id=40, code="height", unit="cm") + + @classmethod + def get_or_create_body_fat_percentage(cls) -> SeriesTypeDefinition: + """Get the pre-seeded body_fat_percentage series type (ID=42).""" + session = cls._meta.sqlalchemy_session + if session: + existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 42).first() + if existing: + return existing + return cls(id=42, code="body_fat_percentage", unit="percent") + + @classmethod + def get_or_create_lean_body_mass(cls) -> SeriesTypeDefinition: + """Get the pre-seeded lean_body_mass series type (ID=44).""" + session = cls._meta.sqlalchemy_session + if session: + existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 44).first() + if existing: + return existing + return cls(id=44, code="lean_body_mass", unit="kg") + + @classmethod + def get_or_create_resting_heart_rate(cls) -> SeriesTypeDefinition: + """Get the pre-seeded resting_heart_rate series type (ID=2).""" + session = cls._meta.sqlalchemy_session + if session: + existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 2).first() + if existing: + return existing + return cls(id=2, code="resting_heart_rate", unit="bpm") + + @classmethod + def get_or_create_heart_rate_variability_sdnn(cls) -> SeriesTypeDefinition: + """Get the pre-seeded heart_rate_variability_sdnn series type (ID=3).""" + session = cls._meta.sqlalchemy_session + if session: + existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 3).first() + if existing: + return existing + return cls(id=3, code="heart_rate_variability_sdnn", unit="ms") + + @classmethod + def get_or_create_blood_pressure_systolic(cls) -> SeriesTypeDefinition: + """Get the pre-seeded blood_pressure_systolic series type (ID=22).""" + session = cls._meta.sqlalchemy_session + if session: + existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 22).first() + if existing: + return existing + return cls(id=22, code="blood_pressure_systolic", unit="mmHg") + + @classmethod + def get_or_create_blood_pressure_diastolic(cls) -> SeriesTypeDefinition: + """Get the pre-seeded blood_pressure_diastolic series type (ID=23).""" + session = cls._meta.sqlalchemy_session + if session: + existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 23).first() + if existing: + return existing + return cls(id=23, code="blood_pressure_diastolic", unit="mmHg") + + @classmethod + def get_or_create_body_temperature(cls) -> SeriesTypeDefinition: + """Get the pre-seeded body_temperature series type (ID=45).""" + session = cls._meta.sqlalchemy_session + if session: + existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 45).first() + if existing: + return existing + return cls(id=45, code="body_temperature", unit="celsius") + + +class UserFactory(BaseFactory): + """Factory for User model.""" + + class Meta: + model = User + + id = LazyFunction(uuid4) + created_at = LazyFunction(lambda: datetime.now(timezone.utc)) + email = factory.Faker("email") + first_name = factory.Faker("first_name") + last_name = factory.Faker("last_name") + external_user_id = None + + +class PersonalRecordFactory(BaseFactory): + """Factory for PersonalRecord model.""" + + class Meta: + model = PersonalRecord + + id = LazyFunction(uuid4) + user = factory.SubFactory(UserFactory) + birth_date = None + sex = None + gender = None + + +class DeveloperFactory(BaseFactory): + """Factory for Developer model.""" + + class Meta: + model = Developer + + id = LazyFunction(uuid4) + created_at = LazyFunction(lambda: datetime.now(timezone.utc)) + updated_at = LazyFunction(lambda: datetime.now(timezone.utc)) + email = factory.Faker("email") + hashed_password = LazyAttribute( + lambda o: f"hashed_{o.password}" if hasattr(o, "password") else "hashed_test_password", + ) + + class Params: + password = "test_password" + + +class ApiKeyFactory(BaseFactory): + """Factory for ApiKey model.""" + + class Meta: + model = ApiKey + + id = LazyFunction(lambda: f"sk-{uuid4().hex[:32]}") + name = Sequence(lambda n: f"Test API Key {n}") + created_at = LazyFunction(lambda: datetime.now(timezone.utc)) + + @classmethod + def _create(cls, model_class: type[ApiKey], *args: Any, **kwargs: Any) -> ApiKey: + """Override create to handle developer relationship.""" + developer = kwargs.pop("developer", None) + # Remove any stale created_by that might have been set + kwargs.pop("created_by", None) + if developer is None: + # Create a developer if not provided + developer = DeveloperFactory() + kwargs["created_by"] = developer.id + return super()._create(model_class, *args, **kwargs) + + +class ApplicationFactory(BaseFactory): + """Factory for Application model (SDK apps).""" + + class Meta: + model = Application + + id = LazyFunction(uuid4) + app_id = LazyFunction(lambda: f"app_{uuid4().hex[:32]}") + name = Sequence(lambda n: f"Test Application {n}") + created_at = LazyFunction(lambda: datetime.now(timezone.utc)) + updated_at = LazyFunction(lambda: datetime.now(timezone.utc)) + + @classmethod + def _create(cls, model_class: type[Application], *args: Any, **kwargs: Any) -> Application: + """Override create to handle developer relationship and password hashing.""" + developer = kwargs.pop("developer", None) + # Remove any stale developer_id that might have been set + kwargs.pop("developer_id", None) + if developer is None: + # Create a developer if not provided + developer = DeveloperFactory() + kwargs["developer_id"] = developer.id + + # Handle app_secret -> app_secret_hash conversion with real bcrypt + # Default to "test_app_secret" if not provided + app_secret = kwargs.pop("app_secret", "test_app_secret") + if "app_secret_hash" not in kwargs: + kwargs["app_secret_hash"] = get_password_hash(app_secret) + + return super()._create(model_class, *args, **kwargs) + + +class DataSourceFactory(BaseFactory): + """Factory for DataSource model.""" + + class Meta: + model = DataSource + + id = LazyFunction(uuid4) + provider = ProviderName.APPLE + device_model = LazyFunction(lambda: f"TestDevice-{uuid4().hex[:8]}") + software_version = "1.0.0" + source = "apple_health_sdk" + device_type = "watch" + + @classmethod + def _create( + cls, + model_class: type[DataSource], + *args: Any, + **kwargs: Any, + ) -> DataSource: + user = kwargs.pop("user", None) + kwargs.pop("user_id", None) + + if user is None: + user = UserFactory() + kwargs["user_id"] = user.id + + # Handle provider parameter (ProviderName enum -> enum value) + provider = kwargs.get("provider") + if provider is not None and isinstance(provider, str): + try: + kwargs["provider"] = ProviderName(provider) + except ValueError: + kwargs["provider"] = ProviderName.UNKNOWN + + return super()._create(model_class, *args, **kwargs) + + +# Backward-compatible alias for tests still using the old name +DataSourceFactory = DataSourceFactory + + +class UserConnectionFactory(BaseFactory): + """Factory for UserConnection model.""" + + class Meta: + model = UserConnection + + id = LazyFunction(uuid4) + provider = "garmin" + provider_user_id = LazyFunction(lambda: f"provider_{uuid4().hex[:8]}") + provider_username = factory.Faker("user_name") + access_token = LazyFunction(lambda: f"access_{uuid4().hex}") # Optional for SDK providers + refresh_token = LazyFunction(lambda: f"refresh_{uuid4().hex}") + token_expires_at = LazyFunction(lambda: datetime(2025, 12, 31, tzinfo=timezone.utc)) # Optional for SDK providers + scope = "read_all" + status = ConnectionStatus.ACTIVE + last_synced_at = None + created_at = LazyFunction(lambda: datetime.now(timezone.utc)) + updated_at = LazyFunction(lambda: datetime.now(timezone.utc)) + + @classmethod + def _create(cls, model_class: type[UserConnection], *args: Any, **kwargs: Any) -> UserConnection: + """Override create to handle user relationship.""" + user = kwargs.pop("user", None) + # Remove any stale user_id that might have been set + kwargs.pop("user_id", None) + if user is None: + user = UserFactory() + kwargs["user_id"] = user.id + return super()._create(model_class, *args, **kwargs) + + +class EventRecordFactory(BaseFactory): + """Factory for EventRecord model.""" + + class Meta: + model = EventRecord + + id = LazyFunction(uuid4) + category = "workout" + type = "running" + source_name = "Apple Watch" + duration_seconds = 3600 + start_datetime = LazyFunction(lambda: datetime.now(timezone.utc)) + end_datetime = LazyAttribute( + lambda o: datetime.fromtimestamp(o.start_datetime.timestamp() + (o.duration_seconds or 3600), tz=timezone.utc), + ) + + @classmethod + def _create(cls, model_class: type[EventRecord], *args: Any, **kwargs: Any) -> EventRecord: + """Override create to handle data_source relationship and type_ alias.""" + # Support both "mapping" (legacy) and "data_source" parameter names + data_source = kwargs.pop("data_source", None) or kwargs.pop("mapping", None) + # Remove any stale data_source_id that might have been set + kwargs.pop("data_source_id", None) + if data_source is None: + data_source = DataSourceFactory() + kwargs["data_source_id"] = data_source.id + + # Handle type_ alias + if "type_" in kwargs: + kwargs["type"] = kwargs.pop("type_") + + return super()._create(model_class, *args, **kwargs) + + +class EventRecordDetailFactory(BaseFactory): + """Factory for EventRecordDetail model.""" + + class Meta: + model = EventRecordDetail + + detail_type = "workout" + + @classmethod + def _create( + cls, + model_class: type[EventRecordDetail], + *args: Any, + **kwargs: Any, + ) -> EventRecordDetail: + """Override create to handle event_record relationship.""" + event_record = kwargs.pop("event_record", None) + # Remove any stale record_id that might have been set + kwargs.pop("record_id", None) + if event_record is None: + event_record = EventRecordFactory() + kwargs["record_id"] = event_record.id + return super()._create(model_class, *args, **kwargs) + + +class DataPointSeriesFactory(BaseFactory): + """Factory for DataPointSeries model.""" + + class Meta: + model = DataPointSeries + + id = LazyFunction(uuid4) + value = LazyFunction(lambda: Decimal("72.0")) + recorded_at = LazyFunction(lambda: datetime.now(timezone.utc)) + + @classmethod + def _create(cls, model_class: type[DataPointSeries], *args: Any, **kwargs: Any) -> DataPointSeries: + """Override create to handle relationships.""" + # Support both "mapping" (legacy) and "data_source" parameter names + data_source = kwargs.pop("data_source", None) or kwargs.pop("mapping", None) + series_type = kwargs.pop("series_type", None) + + if data_source is None: + data_source = DataSourceFactory() + if series_type is None: + # Use the pre-seeded heart_rate series type + series_type = SeriesTypeDefinitionFactory.get_or_create_heart_rate() + + kwargs["data_source_id"] = data_source.id + kwargs["series_type_definition_id"] = series_type.id + + # Convert value to Decimal if needed + if "value" in kwargs and not isinstance(kwargs["value"], Decimal): + kwargs["value"] = Decimal(str(kwargs["value"])) + + return super()._create(model_class, *args, **kwargs) + + +class ProviderSettingFactory(BaseFactory): + """Factory for ProviderSetting model.""" + + class Meta: + model = ProviderSetting + + provider = "garmin" + is_enabled = True + + +class WorkoutDetailsFactory(BaseFactory): + """Factory for WorkoutDetails model.""" + + class Meta: + model = WorkoutDetails + + heart_rate_avg = LazyFunction(lambda: Decimal("145.5")) + heart_rate_max = 175 + heart_rate_min = 95 + steps_count = 8500 + + @classmethod + def _create(cls, model_class: type[WorkoutDetails], *args: Any, **kwargs: Any) -> WorkoutDetails: + """Override create to handle event_record relationship.""" + event_record = kwargs.pop("event_record", None) + # Remove any stale record_id that might have been set + kwargs.pop("record_id", None) + if event_record is None: + event_record = EventRecordFactory(category="workout") + kwargs["record_id"] = event_record.id + + # Convert heart_rate_avg to Decimal if needed + if "heart_rate_avg" in kwargs and not isinstance(kwargs["heart_rate_avg"], Decimal): + kwargs["heart_rate_avg"] = Decimal(str(kwargs["heart_rate_avg"])) + + return super()._create(model_class, *args, **kwargs) + + +class SleepDetailsFactory(BaseFactory): + """Factory for SleepDetails model.""" + + class Meta: + model = SleepDetails + + sleep_total_duration_minutes = 480 # 8 hours + sleep_deep_minutes = 120 # 2 hours + sleep_light_minutes = 240 # 4 hours + sleep_rem_minutes = 90 # 1.5 hours + sleep_awake_minutes = 30 # 30 min + + @classmethod + def _create(cls, model_class: type[SleepDetails], *args: Any, **kwargs: Any) -> SleepDetails: + """Override create to handle event_record relationship.""" + event_record = kwargs.pop("event_record", None) + # Remove any stale record_id that might have been set + kwargs.pop("record_id", None) + if event_record is None: + event_record = EventRecordFactory(category="sleep", type="sleep") + kwargs["record_id"] = event_record.id + return super()._create(model_class, *args, **kwargs) + + +__all__ = [ + "BaseFactory", + "SeriesTypeDefinitionFactory", + "UserFactory", + "DeveloperFactory", + "ApiKeyFactory", + "ApplicationFactory", + "DataSourceFactory", + "DataSourceFactory", # Backward-compatible alias for DataSourceFactory + "UserConnectionFactory", + "EventRecordFactory", + "EventRecordDetailFactory", + "DataPointSeriesFactory", + "ProviderSettingFactory", + "WorkoutDetailsFactory", + "SleepDetailsFactory", +] diff --git a/backend/_tests/integrations/__init__.py b/backend/_tests/integrations/__init__.py new file mode 100644 index 00000000..a2650482 --- /dev/null +++ b/backend/_tests/integrations/__init__.py @@ -0,0 +1 @@ +# Integration tests package diff --git a/backend/tests/integrations/test_apple_import.py b/backend/_tests/integrations/test_apple_import.py similarity index 94% rename from backend/tests/integrations/test_apple_import.py rename to backend/_tests/integrations/test_apple_import.py index ca3ec20c..5de85876 100644 --- a/backend/tests/integrations/test_apple_import.py +++ b/backend/_tests/integrations/test_apple_import.py @@ -9,8 +9,8 @@ from fastapi.testclient import TestClient from sqlalchemy.orm import Session -from tests.factories import ApiKeyFactory, DeveloperFactory, UserFactory -from tests.utils import api_key_headers +from _tests.factories import ApiKeyFactory, DeveloperFactory, UserFactory +from _tests.utils import api_key_headers class TestAppleXMLImport: diff --git a/backend/tests/integrations/test_apple_sdk_import.py b/backend/_tests/integrations/test_apple_sdk_import.py similarity index 99% rename from backend/tests/integrations/test_apple_sdk_import.py rename to backend/_tests/integrations/test_apple_sdk_import.py index a8c42668..a792c92e 100644 --- a/backend/tests/integrations/test_apple_sdk_import.py +++ b/backend/_tests/integrations/test_apple_sdk_import.py @@ -14,7 +14,7 @@ from app.models import EventRecord, WorkoutDetails from app.services.apple.healthkit.import_service import ImportService -from tests.factories import UserFactory +from _tests.factories import UserFactory @pytest.fixture(autouse=True) diff --git a/backend/tests/integrations/test_garmin_import.py b/backend/_tests/integrations/test_garmin_import.py similarity index 99% rename from backend/tests/integrations/test_garmin_import.py rename to backend/_tests/integrations/test_garmin_import.py index 0ffae9dd..34d272aa 100644 --- a/backend/tests/integrations/test_garmin_import.py +++ b/backend/_tests/integrations/test_garmin_import.py @@ -13,7 +13,7 @@ from app.services.providers.garmin.strategy import GarminStrategy from app.services.providers.garmin.workouts import GarminWorkouts -from tests.factories import UserConnectionFactory, UserFactory +from _tests.factories import UserConnectionFactory, UserFactory class TestGarminWorkoutImport: diff --git a/backend/tests/integrations/test_polar_import.py b/backend/_tests/integrations/test_polar_import.py similarity index 98% rename from backend/tests/integrations/test_polar_import.py rename to backend/_tests/integrations/test_polar_import.py index e2b7f550..9dde3f72 100644 --- a/backend/tests/integrations/test_polar_import.py +++ b/backend/_tests/integrations/test_polar_import.py @@ -11,8 +11,8 @@ from fastapi.testclient import TestClient from sqlalchemy.orm import Session -from tests.factories import ApiKeyFactory, DeveloperFactory, UserConnectionFactory, UserFactory -from tests.utils import api_key_headers +from _tests.factories import ApiKeyFactory, DeveloperFactory, UserConnectionFactory, UserFactory +from _tests.utils import api_key_headers @pytest.fixture diff --git a/backend/tests/integrations/test_provider_oauth.py b/backend/_tests/integrations/test_provider_oauth.py similarity index 97% rename from backend/tests/integrations/test_provider_oauth.py rename to backend/_tests/integrations/test_provider_oauth.py index 95eec1fa..ba0708a4 100644 --- a/backend/tests/integrations/test_provider_oauth.py +++ b/backend/_tests/integrations/test_provider_oauth.py @@ -11,8 +11,8 @@ from sqlalchemy.orm import Session from app.schemas.oauth import ConnectionStatus -from tests.factories import DeveloperFactory, UserConnectionFactory, UserFactory -from tests.utils import api_key_headers, developer_auth_headers +from _tests.factories import DeveloperFactory, UserConnectionFactory, UserFactory +from _tests.utils import api_key_headers, developer_auth_headers class TestGarminOAuth: @@ -282,7 +282,7 @@ def test_list_user_connections( ) -> None: """Test listing user's OAuth connections.""" # Arrange - from tests.factories import ApiKeyFactory + from _tests.factories import ApiKeyFactory user = UserFactory() developer = DeveloperFactory() @@ -315,7 +315,7 @@ def test_connection_status_check( ) -> None: """Test checking connection status.""" # Arrange - from tests.factories import ApiKeyFactory + from _tests.factories import ApiKeyFactory user = UserFactory() developer = DeveloperFactory() diff --git a/backend/tests/integrations/test_suunto_import.py b/backend/_tests/integrations/test_suunto_import.py similarity index 99% rename from backend/tests/integrations/test_suunto_import.py rename to backend/_tests/integrations/test_suunto_import.py index 941c8504..516a6add 100644 --- a/backend/tests/integrations/test_suunto_import.py +++ b/backend/_tests/integrations/test_suunto_import.py @@ -20,7 +20,7 @@ from app.schemas.oauth import ConnectionStatus from app.services.providers.suunto.strategy import SuuntoStrategy from app.services.providers.suunto.workouts import SuuntoWorkouts -from tests.factories import UserConnectionFactory, UserFactory +from _tests.factories import UserConnectionFactory, UserFactory class TestSuuntoImport: diff --git a/backend/tests/providers/templates/__init__.py b/backend/_tests/providers/__init__.py similarity index 100% rename from backend/tests/providers/templates/__init__.py rename to backend/_tests/providers/__init__.py diff --git a/backend/_tests/providers/apple/__init__.py b/backend/_tests/providers/apple/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/providers/apple/test_apple_handlers.py b/backend/_tests/providers/apple/test_apple_handlers.py similarity index 100% rename from backend/tests/providers/apple/test_apple_handlers.py rename to backend/_tests/providers/apple/test_apple_handlers.py diff --git a/backend/tests/providers/apple/test_apple_strategy.py b/backend/_tests/providers/apple/test_apple_strategy.py similarity index 100% rename from backend/tests/providers/apple/test_apple_strategy.py rename to backend/_tests/providers/apple/test_apple_strategy.py diff --git a/backend/_tests/providers/conftest.py b/backend/_tests/providers/conftest.py new file mode 100644 index 00000000..e6418db9 --- /dev/null +++ b/backend/_tests/providers/conftest.py @@ -0,0 +1,193 @@ +""" +Provider-specific test fixtures. + +These fixtures provide mock data and utilities for testing provider integrations. +""" + +from unittest.mock import MagicMock + +import pytest + + +@pytest.fixture +def mock_httpx_response() -> MagicMock: + """Mock httpx response for provider API calls.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {} + mock_response.raise_for_status.return_value = None + return mock_response + + +@pytest.fixture +def sample_garmin_activity() -> dict: + """Sample Garmin activity JSON data.""" + return { + "activityId": 12345678901, + "activityName": "Morning Run", + "activityType": {"typeKey": "running"}, + "startTimeLocal": "2024-01-15T08:00:00", + "startTimeGMT": "2024-01-15T07:00:00", + "duration": 3600.0, + "distance": 10000.0, + "averageHR": 145.0, + "maxHR": 175, + "calories": 650.0, + "steps": 8500, + } + + +@pytest.fixture +def sample_garmin_heart_rate_samples() -> list[dict]: + """Sample Garmin heart rate time series data.""" + return [ + {"startTimeGMT": "2024-01-15T07:00:00", "heartRate": 120}, + {"startTimeGMT": "2024-01-15T07:01:00", "heartRate": 135}, + {"startTimeGMT": "2024-01-15T07:02:00", "heartRate": 145}, + {"startTimeGMT": "2024-01-15T07:03:00", "heartRate": 150}, + {"startTimeGMT": "2024-01-15T07:04:00", "heartRate": 155}, + ] + + +@pytest.fixture +def sample_polar_exercise() -> dict: + """Sample Polar exercise JSON data.""" + return { + "id": "ABC123", + "upload_time": "2024-01-15T09:00:00.000Z", + "polar_user": "https://www.polaraccesslink.com/v3/users/12345", + "transaction_id": 67890, + "device": "Polar Vantage V2", + "device_id": "12345678", + "start_time": "2024-01-15T08:00:00", + "start_time_utc_offset": 60, + "duration": "PT1H0M0S", + "calories": 650, + "distance": 10000, + "heart_rate": { + "average": 145, + "maximum": 175, + }, + "training_load": 150.0, + "sport": "RUNNING", + "has_route": True, + "detailed_sport_info": "RUNNING", + } + + +@pytest.fixture +def sample_polar_heart_rate_zones() -> dict: + """Sample Polar heart rate zones data.""" + return { + "zone_1": {"lower_limit": 93, "upper_limit": 111, "in_zone": "PT10M"}, + "zone_2": {"lower_limit": 111, "upper_limit": 130, "in_zone": "PT15M"}, + "zone_3": {"lower_limit": 130, "upper_limit": 149, "in_zone": "PT20M"}, + "zone_4": {"lower_limit": 149, "upper_limit": 167, "in_zone": "PT10M"}, + "zone_5": {"lower_limit": 167, "upper_limit": 186, "in_zone": "PT5M"}, + } + + +@pytest.fixture +def sample_suunto_workout() -> dict: + """Sample Suunto workout JSON data.""" + return { + "workoutKey": "suunto-workout-123", + "activityId": 1, + "workoutName": "Morning Run", + "startTime": 1705309200000, # 2024-01-15T08:00:00 in milliseconds + "totalTime": 3600000, # 1 hour in milliseconds + "totalDistance": 10000.0, + "totalAscent": 150.0, + "totalDescent": 140.0, + "maxSpeed": 15.0, + "avgSpeed": 10.0, + "avgHR": 145, + "maxHR": 175, + "avgCadence": 85, + "totalCalories": 650, + } + + +@pytest.fixture +def sample_suunto_samples() -> dict: + """Sample Suunto workout samples data.""" + return { + "Samples": [ + {"TimeISO8601": "2024-01-15T08:00:00Z", "HR": 120}, + {"TimeISO8601": "2024-01-15T08:01:00Z", "HR": 135}, + {"TimeISO8601": "2024-01-15T08:02:00Z", "HR": 145}, + ], + } + + +@pytest.fixture +def sample_apple_auto_export_workout() -> dict: + """Sample Apple Auto Export workout JSON data.""" + return { + "id": "apple-workout-123", + "name": "Running", + "start": "2024-01-15T08:00:00-05:00", + "end": "2024-01-15T09:00:00-05:00", + "duration": 3600.0, + "distance": {"qty": 10000.0, "units": "m"}, + "activeEnergy": {"qty": 650.0, "units": "kcal"}, + "heartRateData": [ + {"date": "2024-01-15T08:00:00-05:00", "qty": 120.0}, + {"date": "2024-01-15T08:01:00-05:00", "qty": 135.0}, + {"date": "2024-01-15T08:02:00-05:00", "qty": 145.0}, + ], + "stepCount": [ + {"date": "2024-01-15T08:00:00-05:00", "qty": 100.0}, + {"date": "2024-01-15T08:01:00-05:00", "qty": 95.0}, + ], + } + + +@pytest.fixture +def sample_apple_healthkit_workout() -> dict: + """Sample Apple HealthKit workout JSON data.""" + return { + "uuid": "12345678-1234-1234-1234-123456789012", + "workoutActivityType": "HKWorkoutActivityTypeRunning", + "duration": 3600.0, + "totalDistance": 10000.0, + "totalEnergyBurned": 650.0, + "startDate": "2024-01-15T08:00:00-05:00", + "endDate": "2024-01-15T09:00:00-05:00", + "sourceName": "Apple Watch", + "sourceVersion": "10.0", + "device": "Apple Watch Series 9", + } + + +@pytest.fixture +def mock_oauth_token_response() -> dict: + """Mock OAuth token exchange response.""" + return { + "access_token": "test_access_token_abc123", + "refresh_token": "test_refresh_token_xyz789", + "expires_in": 3600, + "token_type": "Bearer", + "scope": "activity:read profile:read", + } + + +@pytest.fixture +def mock_oauth_refresh_response() -> dict: + """Mock OAuth token refresh response.""" + return { + "access_token": "new_access_token_def456", + "refresh_token": "new_refresh_token_uvw123", + "expires_in": 3600, + "token_type": "Bearer", + } + + +@pytest.fixture +def mock_provider_user_info() -> dict: + """Mock provider user info response.""" + return { + "user_id": "provider_user_12345", + "username": "test_user", + "email": "test@example.com", + } diff --git a/backend/_tests/providers/garmin/__init__.py b/backend/_tests/providers/garmin/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/providers/garmin/test_garmin_247.py b/backend/_tests/providers/garmin/test_garmin_247.py similarity index 99% rename from backend/tests/providers/garmin/test_garmin_247.py rename to backend/_tests/providers/garmin/test_garmin_247.py index 26d39b2d..242dbedb 100644 --- a/backend/tests/providers/garmin/test_garmin_247.py +++ b/backend/_tests/providers/garmin/test_garmin_247.py @@ -12,7 +12,7 @@ from app.repositories.user_connection_repository import UserConnectionRepository from app.services.providers.garmin.data_247 import Garmin247Data from app.services.providers.garmin.oauth import GarminOAuth -from tests.factories import UserConnectionFactory, UserFactory +from _tests.factories import UserConnectionFactory, UserFactory class TestGarmin247Data: diff --git a/backend/tests/providers/garmin/test_garmin_backfill.py b/backend/_tests/providers/garmin/test_garmin_backfill.py similarity index 100% rename from backend/tests/providers/garmin/test_garmin_backfill.py rename to backend/_tests/providers/garmin/test_garmin_backfill.py diff --git a/backend/tests/providers/garmin/test_garmin_oauth.py b/backend/_tests/providers/garmin/test_garmin_oauth.py similarity index 99% rename from backend/tests/providers/garmin/test_garmin_oauth.py rename to backend/_tests/providers/garmin/test_garmin_oauth.py index 7daa1253..a14cdb94 100644 --- a/backend/tests/providers/garmin/test_garmin_oauth.py +++ b/backend/_tests/providers/garmin/test_garmin_oauth.py @@ -12,7 +12,7 @@ from app.repositories.user_repository import UserRepository from app.schemas import AuthenticationMethod, OAuthTokenResponse, ProviderCredentials, ProviderEndpoints from app.services.providers.garmin.oauth import GarminOAuth -from tests.factories import UserConnectionFactory, UserFactory +from _tests.factories import UserConnectionFactory, UserFactory class TestGarminOAuth: diff --git a/backend/tests/providers/garmin/test_garmin_strategy.py b/backend/_tests/providers/garmin/test_garmin_strategy.py similarity index 100% rename from backend/tests/providers/garmin/test_garmin_strategy.py rename to backend/_tests/providers/garmin/test_garmin_strategy.py diff --git a/backend/tests/providers/garmin/test_garmin_workouts.py b/backend/_tests/providers/garmin/test_garmin_workouts.py similarity index 99% rename from backend/tests/providers/garmin/test_garmin_workouts.py rename to backend/_tests/providers/garmin/test_garmin_workouts.py index 3a7a04d1..688117b5 100644 --- a/backend/tests/providers/garmin/test_garmin_workouts.py +++ b/backend/_tests/providers/garmin/test_garmin_workouts.py @@ -16,7 +16,7 @@ from app.schemas.workout_types import WorkoutType from app.services.providers.garmin.oauth import GarminOAuth from app.services.providers.garmin.workouts import GarminWorkouts -from tests.factories import UserConnectionFactory, UserFactory +from _tests.factories import UserConnectionFactory, UserFactory class TestGarminWorkouts: diff --git a/backend/_tests/providers/polar/__init__.py b/backend/_tests/providers/polar/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/providers/polar/test_polar_oauth.py b/backend/_tests/providers/polar/test_polar_oauth.py similarity index 99% rename from backend/tests/providers/polar/test_polar_oauth.py rename to backend/_tests/providers/polar/test_polar_oauth.py index d223ad25..f52a88fd 100644 --- a/backend/tests/providers/polar/test_polar_oauth.py +++ b/backend/_tests/providers/polar/test_polar_oauth.py @@ -11,7 +11,7 @@ from app.schemas import OAuthTokenResponse from app.services.providers.polar.oauth import PolarOAuth -from tests.factories import UserFactory +from _tests.factories import UserFactory class TestPolarOAuthConfiguration: diff --git a/backend/tests/providers/polar/test_polar_strategy.py b/backend/_tests/providers/polar/test_polar_strategy.py similarity index 100% rename from backend/tests/providers/polar/test_polar_strategy.py rename to backend/_tests/providers/polar/test_polar_strategy.py diff --git a/backend/tests/providers/polar/test_polar_workouts.py b/backend/_tests/providers/polar/test_polar_workouts.py similarity index 99% rename from backend/tests/providers/polar/test_polar_workouts.py rename to backend/_tests/providers/polar/test_polar_workouts.py index 8e1d6777..9e9820f4 100644 --- a/backend/tests/providers/polar/test_polar_workouts.py +++ b/backend/_tests/providers/polar/test_polar_workouts.py @@ -14,7 +14,7 @@ from app.schemas import PolarExerciseJSON from app.schemas.workout_types import WorkoutType from app.services.providers.polar.workouts import PolarWorkouts -from tests.factories import UserConnectionFactory, UserFactory +from _tests.factories import UserConnectionFactory, UserFactory class TestPolarWorkoutsInitialization: diff --git a/backend/_tests/providers/suunto/__init__.py b/backend/_tests/providers/suunto/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/providers/suunto/test_suunto_oauth.py b/backend/_tests/providers/suunto/test_suunto_oauth.py similarity index 99% rename from backend/tests/providers/suunto/test_suunto_oauth.py rename to backend/_tests/providers/suunto/test_suunto_oauth.py index 2cbe258a..94e29497 100644 --- a/backend/tests/providers/suunto/test_suunto_oauth.py +++ b/backend/_tests/providers/suunto/test_suunto_oauth.py @@ -170,7 +170,7 @@ def test_exchange_token_success(self, mock_post: MagicMock, suunto_oauth: Suunto def test_refresh_access_token_success(self, mock_post: MagicMock, suunto_oauth: SuuntoOAuth, db: Session) -> None: """Should refresh access token using refresh token.""" # Arrange - from tests.factories import UserConnectionFactory, UserFactory + from _tests.factories import UserConnectionFactory, UserFactory user = UserFactory() UserConnectionFactory( diff --git a/backend/tests/providers/suunto/test_suunto_strategy.py b/backend/_tests/providers/suunto/test_suunto_strategy.py similarity index 100% rename from backend/tests/providers/suunto/test_suunto_strategy.py rename to backend/_tests/providers/suunto/test_suunto_strategy.py diff --git a/backend/tests/providers/suunto/test_suunto_workouts.py b/backend/_tests/providers/suunto/test_suunto_workouts.py similarity index 98% rename from backend/tests/providers/suunto/test_suunto_workouts.py rename to backend/_tests/providers/suunto/test_suunto_workouts.py index b282a4b8..94611f8b 100644 --- a/backend/tests/providers/suunto/test_suunto_workouts.py +++ b/backend/_tests/providers/suunto/test_suunto_workouts.py @@ -258,7 +258,7 @@ def test_get_workouts_from_api( ) -> None: """Should fetch workouts from Suunto API with correct parameters.""" # Arrange - from tests.factories import UserFactory + from _tests.factories import UserFactory user = UserFactory() mock_request.return_value = {"payload": [sample_workout_data]} @@ -290,7 +290,7 @@ def test_get_workouts_respects_max_limit( ) -> None: """Should respect maximum limit of 100 workouts per request.""" # Arrange - from tests.factories import UserFactory + from _tests.factories import UserFactory user = UserFactory() mock_request.return_value = {"payload": []} @@ -306,7 +306,7 @@ def test_get_workouts_respects_max_limit( def test_get_workout_detail(self, mock_request: MagicMock, suunto_workouts: SuuntoWorkouts, db: Session) -> None: """Should fetch detailed workout data from API.""" # Arrange - from tests.factories import UserFactory + from _tests.factories import UserFactory user = UserFactory() workout_key = "suunto-workout-123" @@ -338,7 +338,7 @@ def test_load_data_creates_records( ) -> None: """Should load data and create event records.""" # Arrange - from tests.factories import UserFactory + from _tests.factories import UserFactory user = UserFactory() mock_request.return_value = {"payload": [sample_workout_data]} diff --git a/backend/_tests/providers/templates/__init__.py b/backend/_tests/providers/templates/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/providers/templates/test_base_templates.py b/backend/_tests/providers/templates/test_base_templates.py similarity index 100% rename from backend/tests/providers/templates/test_base_templates.py rename to backend/_tests/providers/templates/test_base_templates.py diff --git a/backend/tests/providers/test_base_strategy.py b/backend/_tests/providers/test_base_strategy.py similarity index 100% rename from backend/tests/providers/test_base_strategy.py rename to backend/_tests/providers/test_base_strategy.py diff --git a/backend/tests/providers/test_provider_factory.py b/backend/_tests/providers/test_provider_factory.py similarity index 100% rename from backend/tests/providers/test_provider_factory.py rename to backend/_tests/providers/test_provider_factory.py diff --git a/backend/_tests/repositories/__init__.py b/backend/_tests/repositories/__init__.py new file mode 100644 index 00000000..a2215367 --- /dev/null +++ b/backend/_tests/repositories/__init__.py @@ -0,0 +1 @@ +# Repository tests package diff --git a/backend/tests/repositories/test_api_key_repository.py b/backend/_tests/repositories/test_api_key_repository.py similarity index 99% rename from backend/tests/repositories/test_api_key_repository.py rename to backend/_tests/repositories/test_api_key_repository.py index 2e0eeb50..b388de9e 100644 --- a/backend/tests/repositories/test_api_key_repository.py +++ b/backend/_tests/repositories/test_api_key_repository.py @@ -17,7 +17,7 @@ from app.models import ApiKey from app.repositories.api_key_repository import ApiKeyRepository from app.schemas.api_key import ApiKeyCreate, ApiKeyUpdate -from tests.factories import ApiKeyFactory, DeveloperFactory +from _tests.factories import ApiKeyFactory, DeveloperFactory def _str_id_as_uuid(str_id: str) -> UUID: diff --git a/backend/tests/repositories/test_application_repository.py b/backend/_tests/repositories/test_application_repository.py similarity index 98% rename from backend/tests/repositories/test_application_repository.py rename to backend/_tests/repositories/test_application_repository.py index 510dcad4..21baec12 100644 --- a/backend/tests/repositories/test_application_repository.py +++ b/backend/_tests/repositories/test_application_repository.py @@ -5,7 +5,7 @@ from app.models import Application from app.repositories.application_repository import ApplicationRepository -from tests.factories import ApplicationFactory, DeveloperFactory +from _tests.factories import ApplicationFactory, DeveloperFactory @pytest.fixture diff --git a/backend/tests/repositories/test_data_point_series_repository.py b/backend/_tests/repositories/test_data_point_series_repository.py similarity index 99% rename from backend/tests/repositories/test_data_point_series_repository.py rename to backend/_tests/repositories/test_data_point_series_repository.py index 4ed13fd4..2e9c64a5 100644 --- a/backend/tests/repositories/test_data_point_series_repository.py +++ b/backend/_tests/repositories/test_data_point_series_repository.py @@ -19,7 +19,7 @@ from app.repositories.data_point_series_repository import DataPointSeriesRepository from app.schemas.series_types import SeriesType from app.schemas.timeseries import TimeSeriesQueryParams, TimeSeriesSampleCreate -from tests.factories import DataSourceFactory, UserFactory +from _tests.factories import DataSourceFactory, UserFactory class TestDataPointSeriesRepository: diff --git a/backend/tests/repositories/test_developer_repository.py b/backend/_tests/repositories/test_developer_repository.py similarity index 99% rename from backend/tests/repositories/test_developer_repository.py rename to backend/_tests/repositories/test_developer_repository.py index 3095a266..60c9b720 100644 --- a/backend/tests/repositories/test_developer_repository.py +++ b/backend/_tests/repositories/test_developer_repository.py @@ -16,7 +16,7 @@ from app.models import Developer from app.repositories.developer_repository import DeveloperRepository from app.schemas.developer import DeveloperCreateInternal, DeveloperUpdateInternal -from tests.factories import DeveloperFactory +from _tests.factories import DeveloperFactory class TestDeveloperRepository: diff --git a/backend/tests/repositories/test_device_type_priority_repository.py b/backend/_tests/repositories/test_device_type_priority_repository.py similarity index 100% rename from backend/tests/repositories/test_device_type_priority_repository.py rename to backend/_tests/repositories/test_device_type_priority_repository.py diff --git a/backend/tests/repositories/test_event_record_detail_repository.py b/backend/_tests/repositories/test_event_record_detail_repository.py similarity index 99% rename from backend/tests/repositories/test_event_record_detail_repository.py rename to backend/_tests/repositories/test_event_record_detail_repository.py index b5fe23ce..09a27fcc 100644 --- a/backend/tests/repositories/test_event_record_detail_repository.py +++ b/backend/_tests/repositories/test_event_record_detail_repository.py @@ -19,7 +19,7 @@ from app.models import EventRecordDetail, SleepDetails, WorkoutDetails from app.repositories.event_record_detail_repository import EventRecordDetailRepository from app.schemas.event_record_detail import EventRecordDetailCreate, EventRecordDetailUpdate -from tests.factories import EventRecordFactory, SleepDetailsFactory, WorkoutDetailsFactory +from _tests.factories import EventRecordFactory, SleepDetailsFactory, WorkoutDetailsFactory class TestEventRecordDetailRepository: diff --git a/backend/tests/repositories/test_event_record_repository.py b/backend/_tests/repositories/test_event_record_repository.py similarity index 99% rename from backend/tests/repositories/test_event_record_repository.py rename to backend/_tests/repositories/test_event_record_repository.py index bec75f3b..d05b2ec2 100644 --- a/backend/tests/repositories/test_event_record_repository.py +++ b/backend/_tests/repositories/test_event_record_repository.py @@ -18,7 +18,7 @@ from app.models import EventRecord from app.repositories.event_record_repository import EventRecordRepository from app.schemas.event_record import EventRecordCreate, EventRecordQueryParams -from tests.factories import DataSourceFactory, EventRecordFactory, UserFactory +from _tests.factories import DataSourceFactory, EventRecordFactory, UserFactory class TestEventRecordRepository: diff --git a/backend/tests/repositories/test_provider_priority_repository.py b/backend/_tests/repositories/test_provider_priority_repository.py similarity index 100% rename from backend/tests/repositories/test_provider_priority_repository.py rename to backend/_tests/repositories/test_provider_priority_repository.py diff --git a/backend/tests/repositories/test_provider_settings_repository.py b/backend/_tests/repositories/test_provider_settings_repository.py similarity index 99% rename from backend/tests/repositories/test_provider_settings_repository.py rename to backend/_tests/repositories/test_provider_settings_repository.py index c20ba64a..adfb56ce 100644 --- a/backend/tests/repositories/test_provider_settings_repository.py +++ b/backend/_tests/repositories/test_provider_settings_repository.py @@ -12,7 +12,7 @@ from sqlalchemy.orm import Session from app.repositories.provider_settings_repository import ProviderSettingsRepository -from tests.factories import ProviderSettingFactory +from _tests.factories import ProviderSettingFactory class TestProviderSettingsRepository: diff --git a/backend/tests/repositories/test_refresh_token_repository.py b/backend/_tests/repositories/test_refresh_token_repository.py similarity index 99% rename from backend/tests/repositories/test_refresh_token_repository.py rename to backend/_tests/repositories/test_refresh_token_repository.py index 868b2dd6..fe5e1f65 100644 --- a/backend/tests/repositories/test_refresh_token_repository.py +++ b/backend/_tests/repositories/test_refresh_token_repository.py @@ -9,7 +9,7 @@ from app.models import RefreshToken from app.repositories.refresh_token_repository import refresh_token_repository from app.schemas.token_type import TokenType -from tests.factories import DeveloperFactory, UserFactory +from _tests.factories import DeveloperFactory, UserFactory class TestRefreshTokenRepository: diff --git a/backend/tests/repositories/test_user_connection_repository.py b/backend/_tests/repositories/test_user_connection_repository.py similarity index 99% rename from backend/tests/repositories/test_user_connection_repository.py rename to backend/_tests/repositories/test_user_connection_repository.py index c3b60c82..c11e7df8 100644 --- a/backend/tests/repositories/test_user_connection_repository.py +++ b/backend/_tests/repositories/test_user_connection_repository.py @@ -18,7 +18,7 @@ from app.models import UserConnection from app.repositories.user_connection_repository import UserConnectionRepository from app.schemas.oauth import ConnectionStatus, UserConnectionCreate, UserConnectionUpdate -from tests.factories import UserConnectionFactory, UserFactory +from _tests.factories import UserConnectionFactory, UserFactory class TestUserConnectionRepository: diff --git a/backend/tests/repositories/test_user_repository.py b/backend/_tests/repositories/test_user_repository.py similarity index 99% rename from backend/tests/repositories/test_user_repository.py rename to backend/_tests/repositories/test_user_repository.py index 6a24b92a..20592d5e 100644 --- a/backend/tests/repositories/test_user_repository.py +++ b/backend/_tests/repositories/test_user_repository.py @@ -16,7 +16,7 @@ from app.models import User from app.repositories.user_repository import UserRepository from app.schemas.user import UserCreateInternal, UserUpdateInternal -from tests.factories import UserFactory +from _tests.factories import UserFactory class TestUserRepository: diff --git a/backend/_tests/schemas/__init__.py b/backend/_tests/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/schemas/test_provider_name.py b/backend/_tests/schemas/test_provider_name.py similarity index 100% rename from backend/tests/schemas/test_provider_name.py rename to backend/_tests/schemas/test_provider_name.py diff --git a/backend/tests/schemas/test_schema_validation.py b/backend/_tests/schemas/test_schema_validation.py similarity index 100% rename from backend/tests/schemas/test_schema_validation.py rename to backend/_tests/schemas/test_schema_validation.py diff --git a/backend/_tests/services/__init__.py b/backend/_tests/services/__init__.py new file mode 100644 index 00000000..53a6576f --- /dev/null +++ b/backend/_tests/services/__init__.py @@ -0,0 +1 @@ +# Service tests package diff --git a/backend/tests/services/test_api_key_service.py b/backend/_tests/services/test_api_key_service.py similarity index 99% rename from backend/tests/services/test_api_key_service.py rename to backend/_tests/services/test_api_key_service.py index 3d69d6b9..747be999 100644 --- a/backend/tests/services/test_api_key_service.py +++ b/backend/_tests/services/test_api_key_service.py @@ -16,7 +16,7 @@ from sqlalchemy.orm import Session from app.services.api_key_service import api_key_service -from tests.factories import ApiKeyFactory, DeveloperFactory +from _tests.factories import ApiKeyFactory, DeveloperFactory class TestApiKeyServiceCreateApiKey: diff --git a/backend/tests/services/test_application_service.py b/backend/_tests/services/test_application_service.py similarity index 99% rename from backend/tests/services/test_application_service.py rename to backend/_tests/services/test_application_service.py index 9388a223..c0b762bd 100644 --- a/backend/tests/services/test_application_service.py +++ b/backend/_tests/services/test_application_service.py @@ -5,7 +5,7 @@ from sqlalchemy.orm import Session from app.services.application_service import application_service -from tests.factories import DeveloperFactory +from _tests.factories import DeveloperFactory class TestApplicationServiceCreate: diff --git a/backend/tests/services/test_developer_service.py b/backend/_tests/services/test_developer_service.py similarity index 99% rename from backend/tests/services/test_developer_service.py rename to backend/_tests/services/test_developer_service.py index f31f5bc2..22400b83 100644 --- a/backend/tests/services/test_developer_service.py +++ b/backend/_tests/services/test_developer_service.py @@ -16,7 +16,7 @@ from app.schemas.developer import DeveloperCreate, DeveloperUpdate from app.services.developer_service import developer_service -from tests.factories import DeveloperFactory +from _tests.factories import DeveloperFactory class TestDeveloperServiceRegister: diff --git a/backend/tests/services/test_event_record_service.py b/backend/_tests/services/test_event_record_service.py similarity index 99% rename from backend/tests/services/test_event_record_service.py rename to backend/_tests/services/test_event_record_service.py index 77ba0c61..b91426be 100644 --- a/backend/tests/services/test_event_record_service.py +++ b/backend/_tests/services/test_event_record_service.py @@ -15,7 +15,7 @@ from app.schemas.event_record import EventRecordQueryParams from app.schemas.event_record_detail import EventRecordDetailCreate from app.services.event_record_service import event_record_service -from tests.factories import DataSourceFactory, EventRecordFactory, UserFactory +from _tests.factories import DataSourceFactory, EventRecordFactory, UserFactory class TestEventRecordServiceCreateDetail: diff --git a/backend/tests/services/test_priority_service.py b/backend/_tests/services/test_priority_service.py similarity index 100% rename from backend/tests/services/test_priority_service.py rename to backend/_tests/services/test_priority_service.py diff --git a/backend/tests/services/test_provider_settings_service.py b/backend/_tests/services/test_provider_settings_service.py similarity index 100% rename from backend/tests/services/test_provider_settings_service.py rename to backend/_tests/services/test_provider_settings_service.py diff --git a/backend/tests/services/test_refresh_token_service.py b/backend/_tests/services/test_refresh_token_service.py similarity index 99% rename from backend/tests/services/test_refresh_token_service.py rename to backend/_tests/services/test_refresh_token_service.py index 61095f77..186fe0a5 100644 --- a/backend/tests/services/test_refresh_token_service.py +++ b/backend/_tests/services/test_refresh_token_service.py @@ -9,7 +9,7 @@ from app.models import RefreshToken from app.schemas.token_type import TokenType from app.services.refresh_token_service import refresh_token_service -from tests.factories import DeveloperFactory, UserFactory +from _tests.factories import DeveloperFactory, UserFactory class TestCreateSDKRefreshToken: diff --git a/backend/tests/services/test_sdk_token_service.py b/backend/_tests/services/test_sdk_token_service.py similarity index 100% rename from backend/tests/services/test_sdk_token_service.py rename to backend/_tests/services/test_sdk_token_service.py diff --git a/backend/tests/services/test_system_info_service.py b/backend/_tests/services/test_system_info_service.py similarity index 99% rename from backend/tests/services/test_system_info_service.py rename to backend/_tests/services/test_system_info_service.py index fd56cde4..3c018893 100644 --- a/backend/tests/services/test_system_info_service.py +++ b/backend/_tests/services/test_system_info_service.py @@ -12,7 +12,7 @@ from sqlalchemy.orm import Session from app.services.system_info_service import system_info_service -from tests.factories import ( +from _tests.factories import ( DataPointSeriesFactory, DataSourceFactory, EventRecordFactory, diff --git a/backend/tests/services/test_time_series_service.py b/backend/_tests/services/test_time_series_service.py similarity index 99% rename from backend/tests/services/test_time_series_service.py rename to backend/_tests/services/test_time_series_service.py index db4cff42..da767539 100644 --- a/backend/tests/services/test_time_series_service.py +++ b/backend/_tests/services/test_time_series_service.py @@ -20,7 +20,7 @@ TimeSeriesSampleCreate, ) from app.services.timeseries_service import timeseries_service -from tests.factories import ( +from _tests.factories import ( DataPointSeriesFactory, DataSourceFactory, SeriesTypeDefinitionFactory, diff --git a/backend/tests/services/test_user_connection_service.py b/backend/_tests/services/test_user_connection_service.py similarity index 99% rename from backend/tests/services/test_user_connection_service.py rename to backend/_tests/services/test_user_connection_service.py index c2bcf450..302eb792 100644 --- a/backend/tests/services/test_user_connection_service.py +++ b/backend/_tests/services/test_user_connection_service.py @@ -16,7 +16,7 @@ from app.schemas import UserConnectionCreate, UserConnectionUpdate from app.schemas.oauth import ConnectionStatus from app.services.user_connection_service import user_connection_service -from tests.factories import UserConnectionFactory, UserFactory +from _tests.factories import UserConnectionFactory, UserFactory class TestUserConnectionServiceGetActiveCountInRange: diff --git a/backend/tests/services/test_user_invitation_code_service.py b/backend/_tests/services/test_user_invitation_code_service.py similarity index 99% rename from backend/tests/services/test_user_invitation_code_service.py rename to backend/_tests/services/test_user_invitation_code_service.py index 44addd85..59580ad3 100644 --- a/backend/tests/services/test_user_invitation_code_service.py +++ b/backend/_tests/services/test_user_invitation_code_service.py @@ -15,7 +15,7 @@ CODE_LENGTH, user_invitation_code_service, ) -from tests.factories import DeveloperFactory, UserFactory +from _tests.factories import DeveloperFactory, UserFactory class TestGenerate: diff --git a/backend/tests/services/test_user_service.py b/backend/_tests/services/test_user_service.py similarity index 99% rename from backend/tests/services/test_user_service.py rename to backend/_tests/services/test_user_service.py index e6efab23..d18292f2 100644 --- a/backend/tests/services/test_user_service.py +++ b/backend/_tests/services/test_user_service.py @@ -17,7 +17,7 @@ from app.schemas.user import UserCreate, UserUpdate from app.services.user_service import user_service -from tests.factories import UserFactory +from _tests.factories import UserFactory class TestUserServiceCreate: diff --git a/backend/_tests/tasks/__init__.py b/backend/_tests/tasks/__init__.py new file mode 100644 index 00000000..59b99040 --- /dev/null +++ b/backend/_tests/tasks/__init__.py @@ -0,0 +1 @@ +# Celery task tests package diff --git a/backend/_tests/tasks/conftest.py b/backend/_tests/tasks/conftest.py new file mode 100644 index 00000000..f46e6733 --- /dev/null +++ b/backend/_tests/tasks/conftest.py @@ -0,0 +1,46 @@ +""" +Pytest configuration for Celery task tests. + +Provides fixtures specific to testing asynchronous tasks. +""" + +from collections.abc import Generator +from typing import Callable +from unittest.mock import MagicMock, patch + +import pytest +from sqlalchemy.orm import Session + + +@pytest.fixture +def mock_celery_app() -> Generator[MagicMock, None, None]: + """ + Configure Celery for synchronous test execution. + + Sets up Celery to run tasks eagerly (synchronously) during tests + and propagate exceptions immediately. + """ + with patch("celery.current_app") as mock: + mock.conf = { + "task_always_eager": True, + "task_eager_propagates": True, + } + yield mock + + +@pytest.fixture +def mock_session_local() -> Callable[[Session], MagicMock]: + """ + Mock SessionLocal for Celery tasks that create their own sessions. + + Returns a context manager that yields the test database session. + """ + + def _mock_session_context(db: Session) -> MagicMock: + """Create a mock SessionLocal context manager.""" + mock = MagicMock() + mock.__enter__ = MagicMock(return_value=db) + mock.__exit__ = MagicMock(return_value=None) + return mock + + return _mock_session_context diff --git a/backend/tests/tasks/test_finalize_stale_sleep_task.py b/backend/_tests/tasks/test_finalize_stale_sleep_task.py similarity index 100% rename from backend/tests/tasks/test_finalize_stale_sleep_task.py rename to backend/_tests/tasks/test_finalize_stale_sleep_task.py diff --git a/backend/tests/tasks/test_periodic_sync_task.py b/backend/_tests/tasks/test_periodic_sync_task.py similarity index 99% rename from backend/tests/tasks/test_periodic_sync_task.py rename to backend/_tests/tasks/test_periodic_sync_task.py index 38f3b3de..6e39aab2 100644 --- a/backend/tests/tasks/test_periodic_sync_task.py +++ b/backend/_tests/tasks/test_periodic_sync_task.py @@ -10,7 +10,7 @@ from app.integrations.celery.tasks.periodic_sync_task import sync_all_users from app.schemas import ConnectionStatus -from tests.factories import UserConnectionFactory, UserFactory +from _tests.factories import UserConnectionFactory, UserFactory class TestSyncAllUsersTask: diff --git a/backend/tests/tasks/test_poll_sqs_task.py b/backend/_tests/tasks/test_poll_sqs_task.py similarity index 100% rename from backend/tests/tasks/test_poll_sqs_task.py rename to backend/_tests/tasks/test_poll_sqs_task.py diff --git a/backend/tests/tasks/test_process_apple_upload_task.py b/backend/_tests/tasks/test_process_apple_upload_task.py similarity index 99% rename from backend/tests/tasks/test_process_apple_upload_task.py rename to backend/_tests/tasks/test_process_apple_upload_task.py index 515436a0..334af57b 100644 --- a/backend/tests/tasks/test_process_apple_upload_task.py +++ b/backend/_tests/tasks/test_process_apple_upload_task.py @@ -10,7 +10,7 @@ from sqlalchemy.orm import Session from app.integrations.celery.tasks.process_apple_upload_task import process_apple_upload -from tests.factories import UserFactory +from _tests.factories import UserFactory class TestProcessAppleUploadTask: diff --git a/backend/tests/tasks/test_process_upload_task.py b/backend/_tests/tasks/test_process_upload_task.py similarity index 99% rename from backend/tests/tasks/test_process_upload_task.py rename to backend/_tests/tasks/test_process_upload_task.py index 704e6e25..465edac3 100644 --- a/backend/tests/tasks/test_process_upload_task.py +++ b/backend/_tests/tasks/test_process_upload_task.py @@ -15,7 +15,7 @@ _import_xml_data, process_aws_upload, ) -from tests.factories import UserFactory +from _tests.factories import UserFactory class TestProcessUploadTask: diff --git a/backend/tests/tasks/test_sync_vendor_data_task.py b/backend/_tests/tasks/test_sync_vendor_data_task.py similarity index 99% rename from backend/tests/tasks/test_sync_vendor_data_task.py rename to backend/_tests/tasks/test_sync_vendor_data_task.py index 52a94cca..58afc49b 100644 --- a/backend/tests/tasks/test_sync_vendor_data_task.py +++ b/backend/_tests/tasks/test_sync_vendor_data_task.py @@ -13,7 +13,7 @@ sync_vendor_data, ) from app.schemas import ConnectionStatus -from tests.factories import UserConnectionFactory, UserFactory +from _tests.factories import UserConnectionFactory, UserFactory class TestSyncVendorDataTask: diff --git a/backend/_tests/utils/__init__.py b/backend/_tests/utils/__init__.py new file mode 100644 index 00000000..a2ca230a --- /dev/null +++ b/backend/_tests/utils/__init__.py @@ -0,0 +1,9 @@ +# Test utilities package +from .auth import api_key_headers, create_test_token, developer_auth_headers + +__all__ = [ + # Auth helpers + "developer_auth_headers", + "api_key_headers", + "create_test_token", +] diff --git a/backend/_tests/utils/auth.py b/backend/_tests/utils/auth.py new file mode 100644 index 00000000..55ef3ef2 --- /dev/null +++ b/backend/_tests/utils/auth.py @@ -0,0 +1,27 @@ +""" +Authentication helpers for tests. +""" + +from datetime import timedelta +from uuid import UUID + +from app.utils.security import create_access_token + + +def developer_auth_headers(developer_id: UUID | str) -> dict[str, str]: + """Generate JWT Bearer authorization headers for a developer.""" + token = create_access_token(subject=str(developer_id)) + return {"Authorization": f"Bearer {token}"} + + +def api_key_headers(api_key: str) -> dict[str, str]: + """Generate X-Open-Wearables-API-Key headers for API key authentication.""" + return {"X-Open-Wearables-API-Key": api_key} + + +def create_test_token( + developer_id: UUID | str, + expires_delta: timedelta | None = None, +) -> str: + """Create a custom JWT token for testing.""" + return create_access_token(subject=str(developer_id), expires_delta=expires_delta) diff --git a/backend/tests/utils_tests/__init__.py b/backend/_tests/utils_tests/__init__.py similarity index 100% rename from backend/tests/utils_tests/__init__.py rename to backend/_tests/utils_tests/__init__.py diff --git a/backend/tests/utils_tests/test_api_utils.py b/backend/_tests/utils_tests/test_api_utils.py similarity index 100% rename from backend/tests/utils_tests/test_api_utils.py rename to backend/_tests/utils_tests/test_api_utils.py diff --git a/backend/tests/utils_tests/test_auth_utils.py b/backend/_tests/utils_tests/test_auth_utils.py similarity index 99% rename from backend/tests/utils_tests/test_auth_utils.py rename to backend/_tests/utils_tests/test_auth_utils.py index 438d3260..8b0bab57 100644 --- a/backend/tests/utils_tests/test_auth_utils.py +++ b/backend/_tests/utils_tests/test_auth_utils.py @@ -15,7 +15,7 @@ from app.config import settings from app.utils.auth import get_current_developer, get_current_developer_optional from app.utils.security import create_access_token -from tests.factories import DeveloperFactory +from _tests.factories import DeveloperFactory class TestGetCurrentDeveloper: diff --git a/backend/tests/utils_tests/test_conversion.py b/backend/_tests/utils_tests/test_conversion.py similarity index 99% rename from backend/tests/utils_tests/test_conversion.py rename to backend/_tests/utils_tests/test_conversion.py index 9b3dcc4d..c920232c 100644 --- a/backend/tests/utils_tests/test_conversion.py +++ b/backend/_tests/utils_tests/test_conversion.py @@ -10,7 +10,7 @@ from app.schemas import ConnectionStatus from app.utils.conversion import base_to_dict -from tests.factories import ( +from _tests.factories import ( DataPointSeriesFactory, DataSourceFactory, DeveloperFactory, diff --git a/backend/tests/utils_tests/test_dates.py b/backend/_tests/utils_tests/test_dates.py similarity index 100% rename from backend/tests/utils_tests/test_dates.py rename to backend/_tests/utils_tests/test_dates.py diff --git a/backend/tests/utils_tests/test_exceptions.py b/backend/_tests/utils_tests/test_exceptions.py similarity index 100% rename from backend/tests/utils_tests/test_exceptions.py rename to backend/_tests/utils_tests/test_exceptions.py diff --git a/backend/tests/utils_tests/test_hateoas.py b/backend/_tests/utils_tests/test_hateoas.py similarity index 100% rename from backend/tests/utils_tests/test_hateoas.py rename to backend/_tests/utils_tests/test_hateoas.py diff --git a/backend/tests/utils_tests/test_healthcheck.py b/backend/_tests/utils_tests/test_healthcheck.py similarity index 100% rename from backend/tests/utils_tests/test_healthcheck.py rename to backend/_tests/utils_tests/test_healthcheck.py diff --git a/backend/tests/utils_tests/test_sdk_auth.py b/backend/_tests/utils_tests/test_sdk_auth.py similarity index 98% rename from backend/tests/utils_tests/test_sdk_auth.py rename to backend/_tests/utils_tests/test_sdk_auth.py index 57215c21..fbfb683f 100644 --- a/backend/tests/utils_tests/test_sdk_auth.py +++ b/backend/_tests/utils_tests/test_sdk_auth.py @@ -6,7 +6,7 @@ from app.services.sdk_token_service import create_sdk_user_token from app.utils.auth import get_current_developer, get_sdk_auth -from tests.factories import ApiKeyFactory, DeveloperFactory +from _tests.factories import ApiKeyFactory, DeveloperFactory class TestGetSDKAuth: diff --git a/backend/tests/utils_tests/test_security.py b/backend/_tests/utils_tests/test_security.py similarity index 100% rename from backend/tests/utils_tests/test_security.py rename to backend/_tests/utils_tests/test_security.py diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 746eba8a..d1ec704b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -39,8 +39,8 @@ dev = [ "pytest-cov>=6.2.1", "freezegun>=1.5.0", "setuptools>=75.0.0", - "factory-boy>=3.3.0", - "faker>=33.1.0", + "polyfactory>=3.2.0", + "testcontainers[postgres]>=4.14.1", "psycopg-binary>=3.3.2", "ruff>=0.14.7", ] diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py index f73e12cd..e69de29b 100644 --- a/backend/tests/__init__.py +++ b/backend/tests/__init__.py @@ -1 +0,0 @@ -# Tests package for Open Wearables backend diff --git a/backend/tests/api/__init__.py b/backend/tests/api/__init__.py index 42b18b86..e69de29b 100644 --- a/backend/tests/api/__init__.py +++ b/backend/tests/api/__init__.py @@ -1 +0,0 @@ -# API tests package diff --git a/backend/tests/api/v1/__init__.py b/backend/tests/api/v1/__init__.py index 395961b2..e69de29b 100644 --- a/backend/tests/api/v1/__init__.py +++ b/backend/tests/api/v1/__init__.py @@ -1 +0,0 @@ -# API v1 tests package diff --git a/backend/tests/api/v1/conftest.py b/backend/tests/api/v1/conftest.py index 16fce194..69e0aa85 100644 --- a/backend/tests/api/v1/conftest.py +++ b/backend/tests/api/v1/conftest.py @@ -1,5 +1,5 @@ """ -API v1 specific fixtures. +API v1 fixtures — authenticated developer, API key, test user. """ import pytest @@ -7,34 +7,34 @@ from app.models import ApiKey, Developer, User from tests.factories import ApiKeyFactory, DeveloperFactory, UserFactory -from tests.utils import api_key_headers, developer_auth_headers +from tests.utils.auth import api_key_headers, developer_auth_headers @pytest.fixture def developer(db: Session) -> Developer: - """Create a test developer for authentication.""" - return DeveloperFactory(email="test@example.com", password="test_password") + """Authenticated test developer.""" + return DeveloperFactory.create_sync(email="test@example.com", password="test_password") @pytest.fixture def api_key(db: Session, developer: Developer) -> ApiKey: - """Create a test API key.""" - return ApiKeyFactory(developer=developer, name="Test API Key") + """API key owned by the test developer.""" + return ApiKeyFactory.create_sync(developer=developer, name="Test API Key") @pytest.fixture def user(db: Session) -> User: - """Create a test user.""" - return UserFactory() + """A plain test user (data owner).""" + return UserFactory.create_sync() @pytest.fixture def auth_headers(developer: Developer) -> dict[str, str]: - """Get authentication headers for the test developer.""" + """JWT Bearer headers for ``developer``.""" return developer_auth_headers(developer.id) @pytest.fixture def api_key_header(api_key: ApiKey) -> dict[str, str]: - """Get API key headers.""" + """X-Open-Wearables-API-Key headers.""" return api_key_headers(api_key.id) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index d1c72454..48d7d6d5 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,12 +1,23 @@ """ -Main pytest configuration for Open Wearables backend tests. - -Following patterns from know-how-tests.md: -- PostgreSQL test database with transaction rollback -- Auto-use fixtures for global mocking -- Factory pattern for test data +Root conftest for Open Wearables backend tests. + +Uses testcontainers-postgres so every test run gets a fresh, disposable +database — no manual ``CREATE DATABASE open_wearables_test`` step required. + +Key design decisions +──────────────────── +* **session-scoped** Postgres container + engine → container starts once per + ``pytest`` invocation. +* **function-scoped** DB session wrapped in a SAVEPOINT → every test is + automatically rolled back, giving full isolation with near-zero cost. +* ``polyfactory`` factories receive the session through a single autouse + fixture, so they always flush into the correct transaction. +* Redis, Celery and all external HTTP calls are globally mocked via autouse + fixtures — tests never touch real infra besides Postgres. """ +from __future__ import annotations + import os from collections.abc import Generator from typing import Any @@ -14,144 +25,152 @@ import pytest from fastapi.testclient import TestClient -from sqlalchemy import create_engine, event +from sqlalchemy import Connection, Engine, create_engine, event from sqlalchemy.orm import Session, sessionmaker +from testcontainers.postgres import PostgresContainer -# Set test environment before importing app modules +# ── Set test environment BEFORE any app import ────────────────────────────── os.environ["ENV"] = "test" os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only" -os.environ["MASTER_KEY"] = "dGVzdC1tYXN0ZXIta2V5LWZvci10ZXN0aW5nLW9ubHk=" # base64 test key +os.environ["MASTER_KEY"] = "dGVzdC1tYXN0ZXIta2V5LWZvci10ZXN0aW5nLW9ubHk=" # base64 from app.database import BaseDbModel, _get_db_dependency from app.main import api -# Test database URL - uses test PostgreSQL database -TEST_DATABASE_URL = os.environ.get( - "TEST_DATABASE_URL", - "postgresql+psycopg://open-wearables:open-wearables@localhost:5432/open_wearables_test", -) +# ════════════════════════════════════════════════════════════════════════════ +# Database infrastructure (session scope — started once) +# ════════════════════════════════════════════════════════════════════════════ + +POSTGRES_IMAGE = "postgres:16-alpine" + + +@pytest.fixture(scope="session") +def postgres_container() -> Generator[PostgresContainer, None, None]: + """Spin up a throwaway Postgres container for the whole test session.""" + with PostgresContainer(POSTGRES_IMAGE, driver="psycopg") as pg: + yield pg @pytest.fixture(scope="session") -def engine() -> Any: - """Create test database engine and tables.""" +def database_url(postgres_container: PostgresContainer) -> str: + """Return the SQLAlchemy connection URL for the test container.""" + return postgres_container.get_connection_url() + + +@pytest.fixture(scope="session") +def engine(database_url: str) -> Generator[Engine, None, None]: + """Create the engine, run DDL and seed reference data.""" test_engine = create_engine( - TEST_DATABASE_URL, + database_url, pool_pre_ping=True, pool_size=5, max_overflow=10, ) - BaseDbModel.metadata.create_all(bind=test_engine) - - # Seed series type definitions (these need to exist for foreign key constraints) - from sqlalchemy.orm import Session as SessionClass - from app.models import SeriesTypeDefinition - from app.schemas.series_types import SERIES_TYPE_DEFINITIONS + # Create all tables + BaseDbModel.metadata.create_all(bind=test_engine) - with SessionClass(bind=test_engine) as session: - for type_id, enum, unit in SERIES_TYPE_DEFINITIONS: - # Skip series types with codes exceeding VARCHAR(32) limit - if len(enum.value) > 32: - continue - existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == type_id).first() - if not existing: - series_type = SeriesTypeDefinition(id=type_id, code=enum.value, unit=unit) - session.add(series_type) - session.commit() + # Seed series-type definitions (required for FK constraints) + _seed_series_types(test_engine) yield test_engine + BaseDbModel.metadata.drop_all(bind=test_engine) + test_engine.dispose() @pytest.fixture(scope="session") -def session_factory(engine: Any) -> Any: - """Create session factory bound to test engine.""" +def session_factory(engine: Engine) -> sessionmaker[Session]: + """Session factory bound to the test engine.""" return sessionmaker(autocommit=False, autoflush=False, bind=engine) +# ════════════════════════════════════════════════════════════════════════════ +# Per-test database session (function scope — rolled back after each test) +# ════════════════════════════════════════════════════════════════════════════ + + @pytest.fixture -def db(engine: Any, session_factory: Any) -> Generator[Session, None, None]: +def db(engine: Engine, session_factory: sessionmaker[Session]) -> Generator[Session, None, None]: """ - Create a test database session with transaction rollback. - Each test runs in its own transaction that gets rolled back. + Provide a transactional DB session that is rolled back after each test. + + Wraps the session inside ``BEGIN`` → ``SAVEPOINT``. If application code + calls ``session.commit()``, only the savepoint is released and a new one + is created, so the outer transaction stays open and can be rolled back + cleanly at the end. """ - connection = engine.connect() + connection: Connection = engine.connect() transaction = connection.begin() - session = session_factory(bind=connection) + session: Session = session_factory(bind=connection) - # Begin a nested transaction (savepoint) nested = connection.begin_nested() - # If the application code calls commit, restart the savepoint @event.listens_for(session, "after_transaction_end") - def restart_savepoint(session: Session, transaction: Any) -> None: + def _restart_savepoint(sess: Session, trans: Any) -> None: nonlocal nested if not nested.is_active: nested = connection.begin_nested() yield session - # Rollback everything session.close() transaction.rollback() connection.close() +# ════════════════════════════════════════════════════════════════════════════ +# Factory wiring (polyfactory) +# ════════════════════════════════════════════════════════════════════════════ + + @pytest.fixture(autouse=True) -def set_factory_session(db: Session) -> Generator[None, None, None]: - """Set database session for all factory-boy factories.""" +def _wire_factories(db: Session) -> Generator[None, None, None]: + """Inject the current test session into every polyfactory factory.""" from tests import factories - for name, obj in vars(factories).items(): - if isinstance(obj, type) and hasattr(obj, "_meta") and hasattr(obj._meta, "sqlalchemy_session"): - obj._meta.sqlalchemy_session = db + factories.set_session(db) yield - # Clear session after test - for name, obj in vars(factories).items(): - if isinstance(obj, type) and hasattr(obj, "_meta") and hasattr(obj._meta, "sqlalchemy_session"): - obj._meta.sqlalchemy_session = None + factories.clear_session() + + +# ════════════════════════════════════════════════════════════════════════════ +# FastAPI test client +# ════════════════════════════════════════════════════════════════════════════ @pytest.fixture def client(db: Session) -> Generator[TestClient, None, None]: - """ - Create a test client with database dependency override. - """ + """TestClient wired to the per-test DB session.""" - def override_get_db() -> Generator[Session, None, None]: + def _override_db() -> Generator[Session, None, None]: yield db - api.dependency_overrides[_get_db_dependency] = override_get_db + api.dependency_overrides[_get_db_dependency] = _override_db - with TestClient(api) as test_client: - yield test_client + with TestClient(api) as c: + yield c api.dependency_overrides.clear() -# ============================================================================ -# Auto-use fixtures for global mocking -# ============================================================================ +# ════════════════════════════════════════════════════════════════════════════ +# Global mocks (autouse — applied to every test) +# ════════════════════════════════════════════════════════════════════════════ @pytest.fixture(autouse=True) -def mock_redis(monkeypatch: pytest.MonkeyPatch) -> Generator[MagicMock, None, None]: - """Globally mock Redis to prevent connection errors in tests.""" +def _mock_redis() -> Generator[MagicMock, None, None]: + """Prevent any real Redis connection.""" mock = MagicMock() mock.lock.return_value.__enter__ = MagicMock(return_value=None) mock.lock.return_value.__exit__ = MagicMock(return_value=None) - mock.get.return_value = None - mock.set.return_value = True - mock.setex.return_value = True - mock.expire.return_value = True - mock.delete.return_value = True + for attr in ("get", "set", "setex", "expire", "delete"): + setattr(mock, attr, MagicMock(return_value=None if attr == "get" else True)) mock.sadd.return_value = 1 mock.srem.return_value = 1 mock.smembers.return_value = set() - # Return mock for redis.from_url (used by get_redis_client) - # We also need to clear lru_cache of get_redis_client to ensure it picks up the mock from app.integrations.redis_client import get_redis_client get_redis_client.cache_clear() @@ -161,22 +180,24 @@ def mock_redis(monkeypatch: pytest.MonkeyPatch) -> Generator[MagicMock, None, No @pytest.fixture(autouse=True) -def mock_celery_tasks(monkeypatch: pytest.MonkeyPatch) -> Generator[MagicMock, None, None]: - """Mock Celery tasks to run synchronously.""" - # Mock the poll_sqs_task specifically +def _mock_celery() -> Generator[MagicMock, None, None]: + """Run Celery tasks synchronously via mocks.""" mock_task = MagicMock() mock_task.delay.return_value = MagicMock() mock_task.apply_async.return_value = MagicMock() with ( patch("celery.current_app") as mock_celery, - patch("app.integrations.celery.tasks.poll_sqs_task.poll_sqs_task", mock_task), + patch( + "app.integrations.celery.tasks.poll_sqs_task.poll_sqs_task", + mock_task, + ), patch("app.api.routes.v1.import_xml.poll_sqs_task", mock_task), - # Patch the new finalize_stale_sleeps task that was added in this PR - patch("app.integrations.celery.tasks.process_apple_upload_task.finalize_stale_sleeps", mock_task), + patch( + "app.integrations.celery.tasks.process_apple_upload_task.finalize_stale_sleeps", + mock_task, + ), ): - # Configure Celery to use in-memory broker and result backend - # We Mock the conf object to return our test settings mock_conf = MagicMock() mock_conf.__getitem__ = lambda s, k: { "task_always_eager": True, @@ -184,20 +205,14 @@ def mock_celery_tasks(monkeypatch: pytest.MonkeyPatch) -> Generator[MagicMock, N "broker_url": "memory://", "result_backend": "cache+memory://", }.get(k) - - # When update is called, we don't want to actually connect to Redis mock_conf.update = MagicMock() mock_celery.conf = mock_conf - yield mock_task @pytest.fixture(autouse=True) -def mock_external_apis() -> Generator[dict[str, MagicMock], None, None]: - """Mock external API calls (Garmin, Polar, Suunto, AWS).""" - mocks: dict[str, MagicMock] = {} - - # Configure boto3 S3 mock +def _mock_external_apis() -> Generator[dict[str, MagicMock], None, None]: + """Prevent any real HTTP call to Garmin / Polar / Suunto / AWS.""" mock_s3 = MagicMock() mock_s3.generate_presigned_url.return_value = "https://test-bucket.s3.amazonaws.com/test-key" mock_s3.generate_presigned_post.return_value = { @@ -205,59 +220,88 @@ def mock_external_apis() -> Generator[dict[str, MagicMock], None, None]: "fields": { "key": "test-user/raw/test.xml", "Content-Type": "application/xml", - "policy": "test-policy", - "x-amz-algorithm": "AWS4-HMAC-SHA256", - "x-amz-credential": "test-credential", - "x-amz-date": "20251217T000000Z", - "x-amz-signature": "test-signature", }, } mock_s3.head_bucket.return_value = {} mock_s3.put_object.return_value = {"ETag": "test-etag"} + mocks: dict[str, MagicMock] = {} with ( patch("httpx.AsyncClient") as mock_httpx, patch("boto3.client", return_value=mock_s3) as mock_boto3, patch("requests.Session") as mock_requests, - patch("app.services.apple.apple_xml.aws_service.AWS_BUCKET_NAME", "test-bucket"), - patch("app.services.apple.apple_xml.presigned_url_service.AWS_BUCKET_NAME", "test-bucket"), - patch("app.services.apple.apple_xml.aws_service.s3_client", mock_s3), - patch("app.services.apple.apple_xml.presigned_url_service.s3_client", mock_s3), + patch( + "app.services.apple.apple_xml.aws_service.AWS_BUCKET_NAME", + "test-bucket", + ), + patch( + "app.services.apple.apple_xml.presigned_url_service.AWS_BUCKET_NAME", + "test-bucket", + ), + patch( + "app.services.apple.apple_xml.aws_service.s3_client", + mock_s3, + ), + patch( + "app.services.apple.apple_xml.presigned_url_service.s3_client", + mock_s3, + ), ): mocks["httpx"] = mock_httpx mocks["boto3"] = mock_boto3 mocks["requests"] = mock_requests mocks["s3"] = mock_s3 - yield mocks @pytest.fixture(autouse=True) -def fast_password_hashing(monkeypatch: pytest.MonkeyPatch) -> None: - """Speed up tests by using simple password hashing.""" +def _fast_password_hashing(monkeypatch: pytest.MonkeyPatch) -> None: + """Replace bcrypt with trivial hash / verify for speed.""" import sys - def simple_hash(password: str) -> str: + def _hash(password: str) -> str: return f"hashed_{password}" - def simple_verify(plain: str, hashed: str) -> bool: + def _verify(plain: str, hashed: str) -> bool: return hashed == f"hashed_{plain}" - # Patch in the source module - monkeypatch.setattr("app.utils.security.get_password_hash", simple_hash) - monkeypatch.setattr("app.utils.security.verify_password", simple_verify) - # Also patch in modules that import these functions directly (use sys.modules to avoid name shadowing) + monkeypatch.setattr("app.utils.security.get_password_hash", _hash) + monkeypatch.setattr("app.utils.security.verify_password", _verify) if "app.services.developer_service" in sys.modules: - monkeypatch.setattr(sys.modules["app.services.developer_service"], "get_password_hash", simple_hash) - monkeypatch.setattr("app.api.routes.v1.auth.verify_password", simple_verify) + monkeypatch.setattr( + sys.modules["app.services.developer_service"], + "get_password_hash", + _hash, + ) + monkeypatch.setattr("app.api.routes.v1.auth.verify_password", _verify) -# ============================================================================ -# Shared test utilities -# ============================================================================ +# ════════════════════════════════════════════════════════════════════════════ +# Shared convenience fixtures +# ════════════════════════════════════════════════════════════════════════════ @pytest.fixture def api_v1_prefix() -> str: - """Return the API v1 prefix.""" return "/api/v1" + + +# ════════════════════════════════════════════════════════════════════════════ +# Internal helpers +# ════════════════════════════════════════════════════════════════════════════ + + +def _seed_series_types(test_engine: Engine) -> None: + """Insert canonical series-type definitions once per session.""" + from app.models import SeriesTypeDefinition + from app.schemas.series_types import SERIES_TYPE_DEFINITIONS + + with Session(bind=test_engine) as session: + for type_id, enum_member, unit in SERIES_TYPE_DEFINITIONS: + if len(enum_member.value) > 32: + continue + if not session.get(SeriesTypeDefinition, type_id): + session.add( + SeriesTypeDefinition(id=type_id, code=enum_member.value, unit=unit), + ) + session.commit() diff --git a/backend/tests/factories.py b/backend/tests/factories.py index dc59a7d8..a6a93501 100644 --- a/backend/tests/factories.py +++ b/backend/tests/factories.py @@ -1,10 +1,21 @@ """ -Factory-boy factories for creating test data. +Polyfactory-based model factories for Open Wearables. -Usage: - from tests.factories import UserFactory, DeveloperFactory - user = UserFactory() # Session set automatically via conftest fixture - developer = DeveloperFactory(email="custom@example.com") +Unlike the old factory-boy factories, polyfactory introspects +SQLAlchemy ``Mapped[]`` annotations directly, so most columns are +generated automatically. We only override fields that need stable +defaults or cross-model wiring. + +Usage +───── +Factories are **not** instantiated directly. Call the class method:: + + user = UserFactory.create_sync(session=db) + +The ``conftest._wire_factories`` autouse fixture calls ``set_session`` +before every test, so you can also simply do:: + + user = UserFactory.create_sync() """ from __future__ import annotations @@ -14,8 +25,8 @@ from typing import Any from uuid import uuid4 -import factory -from factory import LazyAttribute, LazyFunction, Sequence +from polyfactory.factories.sqlalchemy_factory import SQLAlchemyFactory +from sqlalchemy.orm import Session from app.models import ( ApiKey, @@ -34,525 +45,401 @@ WorkoutDetails, ) from app.schemas.oauth import ConnectionStatus, ProviderName -from app.utils.security import get_password_hash +# ── Session management ────────────────────────────────────────────────────── -class BaseFactory(factory.alchemy.SQLAlchemyModelFactory): - """Base factory for all SQLAlchemy models.""" +_session: Session | None = None - class Meta: - abstract = True - sqlalchemy_session = None # Set per-test via conftest fixture - sqlalchemy_session_persistence = "flush" # Don't commit, let test handle rollback +def set_session(session: Session) -> None: + """Called by conftest before each test.""" + global _session # noqa: PLW0603 + _session = session -class SeriesTypeDefinitionFactory(BaseFactory): - """Factory for SeriesTypeDefinition model. - Note: heart_rate and other standard series types are seeded at session scope. - Use get_or_create_heart_rate() for tests that need the heart_rate type. - """ +def clear_session() -> None: + global _session # noqa: PLW0603 + _session = None - class Meta: - model = SeriesTypeDefinition - code = factory.Sequence(lambda n: f"test_series_type_{n}") - unit = "test_unit" +class _Base(SQLAlchemyFactory[Any]): + """ + Shared base for all model factories. - @classmethod - def get_or_create_heart_rate(cls) -> SeriesTypeDefinition: - """Get the pre-seeded heart_rate series type (ID=1).""" - session = cls._meta.sqlalchemy_session - if session: - existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 1).first() - if existing: - return existing - # Fallback: create new one (shouldn't happen with proper seeding) - return cls(id=1, code="heart_rate", unit="bpm") + * ``__is_base_factory__ = True`` prevents polyfactory from trying to + instantiate ``_Base`` itself. + * ``__set_relationships__`` is off so FK columns are set explicitly + rather than via ORM relationship traversal (avoids double-inserts). + """ - @classmethod - def get_or_create_steps(cls) -> SeriesTypeDefinition: - """Get the pre-seeded steps series type (ID=80).""" - session = cls._meta.sqlalchemy_session - if session: - existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 80).first() - if existing: - return existing - # Fallback: create new one (shouldn't happen with proper seeding) - return cls(id=80, code="steps", unit="count") + __is_base_factory__ = True + __set_relationships__ = False + __session__ = None # overridden per-test via set_session() @classmethod - def get_or_create_energy(cls) -> SeriesTypeDefinition: - """Get the pre-seeded energy (active calories) series type (ID=81).""" - session = cls._meta.sqlalchemy_session - if session: - existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 81).first() - if existing: - return existing - return cls(id=81, code="energy", unit="kcal") + def _get_session(cls) -> Session: + if _session is None: + msg = "Factory session not set — is the _wire_factories fixture active?" + raise RuntimeError(msg) + return _session @classmethod - def get_or_create_basal_energy(cls) -> SeriesTypeDefinition: - """Get the pre-seeded basal_energy series type (ID=82).""" - session = cls._meta.sqlalchemy_session - if session: - existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 82).first() - if existing: - return existing - return cls(id=82, code="basal_energy", unit="kcal") + def create_sync(cls, **kwargs: Any) -> Any: + """Build an instance and flush it to the test DB.""" + session = cls._get_session() + instance = cls.build(**kwargs) + session.add(instance) + session.flush() + return instance - @classmethod - def get_or_create_distance_walking_running(cls) -> SeriesTypeDefinition: - """Get the pre-seeded distance_walking_running series type (ID=100).""" - session = cls._meta.sqlalchemy_session - if session: - existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 100).first() - if existing: - return existing - return cls(id=100, code="distance_walking_running", unit="meters") - @classmethod - def get_or_create_flights_climbed(cls) -> SeriesTypeDefinition: - """Get the pre-seeded flights_climbed series type (ID=86).""" - session = cls._meta.sqlalchemy_session - if session: - existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 86).first() - if existing: - return existing - return cls(id=86, code="flights_climbed", unit="count") +# ════════════════════════════════════════════════════════════════════════════ +# Reference data +# ════════════════════════════════════════════════════════════════════════════ - @classmethod - def get_or_create_weight(cls) -> SeriesTypeDefinition: - """Get the pre-seeded weight series type (ID=41).""" - session = cls._meta.sqlalchemy_session - if session: - existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 41).first() - if existing: - return existing - return cls(id=41, code="weight", unit="kg") - @classmethod - def get_or_create_height(cls) -> SeriesTypeDefinition: - """Get the pre-seeded height series type (ID=40).""" - session = cls._meta.sqlalchemy_session - if session: - existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 40).first() - if existing: - return existing - return cls(id=40, code="height", unit="cm") +class SeriesTypeDefinitionFactory(_Base): + __model__ = SeriesTypeDefinition @classmethod - def get_or_create_body_fat_percentage(cls) -> SeriesTypeDefinition: - """Get the pre-seeded body_fat_percentage series type (ID=42).""" - session = cls._meta.sqlalchemy_session - if session: - existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 42).first() - if existing: - return existing - return cls(id=42, code="body_fat_percentage", unit="percent") + def build(cls, **kwargs: Any) -> SeriesTypeDefinition: + kwargs.setdefault("code", f"test_series_{uuid4().hex[:8]}") + kwargs.setdefault("unit", "unit") + return SeriesTypeDefinition(**kwargs) @classmethod - def get_or_create_lean_body_mass(cls) -> SeriesTypeDefinition: - """Get the pre-seeded lean_body_mass series type (ID=44).""" - session = cls._meta.sqlalchemy_session - if session: - existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 44).first() - if existing: - return existing - return cls(id=44, code="lean_body_mass", unit="kg") - + def get_or_create(cls, type_id: int, code: str, unit: str) -> SeriesTypeDefinition: + """Return the pre-seeded row, or create a new one.""" + session = cls._get_session() + existing = session.get(SeriesTypeDefinition, type_id) + if existing: + return existing + return cls.create_sync(id=type_id, code=code, unit=unit) + + # Convenience accessors for the most-used types @classmethod - def get_or_create_resting_heart_rate(cls) -> SeriesTypeDefinition: - """Get the pre-seeded resting_heart_rate series type (ID=2).""" - session = cls._meta.sqlalchemy_session - if session: - existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 2).first() - if existing: - return existing - return cls(id=2, code="resting_heart_rate", unit="bpm") + def heart_rate(cls) -> SeriesTypeDefinition: + return cls.get_or_create(1, "heart_rate", "bpm") @classmethod - def get_or_create_heart_rate_variability_sdnn(cls) -> SeriesTypeDefinition: - """Get the pre-seeded heart_rate_variability_sdnn series type (ID=3).""" - session = cls._meta.sqlalchemy_session - if session: - existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 3).first() - if existing: - return existing - return cls(id=3, code="heart_rate_variability_sdnn", unit="ms") + def steps(cls) -> SeriesTypeDefinition: + return cls.get_or_create(80, "steps", "count") @classmethod - def get_or_create_blood_pressure_systolic(cls) -> SeriesTypeDefinition: - """Get the pre-seeded blood_pressure_systolic series type (ID=22).""" - session = cls._meta.sqlalchemy_session - if session: - existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 22).first() - if existing: - return existing - return cls(id=22, code="blood_pressure_systolic", unit="mmHg") + def energy(cls) -> SeriesTypeDefinition: + return cls.get_or_create(81, "energy", "kcal") @classmethod - def get_or_create_blood_pressure_diastolic(cls) -> SeriesTypeDefinition: - """Get the pre-seeded blood_pressure_diastolic series type (ID=23).""" - session = cls._meta.sqlalchemy_session - if session: - existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 23).first() - if existing: - return existing - return cls(id=23, code="blood_pressure_diastolic", unit="mmHg") + def resting_heart_rate(cls) -> SeriesTypeDefinition: + return cls.get_or_create(2, "resting_heart_rate", "bpm") @classmethod - def get_or_create_body_temperature(cls) -> SeriesTypeDefinition: - """Get the pre-seeded body_temperature series type (ID=45).""" - session = cls._meta.sqlalchemy_session - if session: - existing = session.query(SeriesTypeDefinition).filter(SeriesTypeDefinition.id == 45).first() - if existing: - return existing - return cls(id=45, code="body_temperature", unit="celsius") - - -class UserFactory(BaseFactory): - """Factory for User model.""" - - class Meta: - model = User + def weight(cls) -> SeriesTypeDefinition: + return cls.get_or_create(41, "weight", "kg") - id = LazyFunction(uuid4) - created_at = LazyFunction(lambda: datetime.now(timezone.utc)) - email = factory.Faker("email") - first_name = factory.Faker("first_name") - last_name = factory.Faker("last_name") - external_user_id = None +# ════════════════════════════════════════════════════════════════════════════ +# Core domain +# ════════════════════════════════════════════════════════════════════════════ -class PersonalRecordFactory(BaseFactory): - """Factory for PersonalRecord model.""" - class Meta: - model = PersonalRecord +class UserFactory(_Base): + __model__ = User - id = LazyFunction(uuid4) - user = factory.SubFactory(UserFactory) - birth_date = None - sex = None - gender = None - - -class DeveloperFactory(BaseFactory): - """Factory for Developer model.""" - - class Meta: - model = Developer + @classmethod + def build(cls, **kwargs: Any) -> User: + kwargs.setdefault("id", uuid4()) + kwargs.setdefault("created_at", datetime.now(timezone.utc)) + kwargs.setdefault("email", f"user-{uuid4().hex[:8]}@test.local") + kwargs.setdefault("first_name", "Test") + kwargs.setdefault("last_name", "User") + kwargs.setdefault("external_user_id", None) + return User(**kwargs) - id = LazyFunction(uuid4) - created_at = LazyFunction(lambda: datetime.now(timezone.utc)) - updated_at = LazyFunction(lambda: datetime.now(timezone.utc)) - email = factory.Faker("email") - hashed_password = LazyAttribute( - lambda o: f"hashed_{o.password}" if hasattr(o, "password") else "hashed_test_password", - ) - class Params: - password = "test_password" +class PersonalRecordFactory(_Base): + __model__ = PersonalRecord + @classmethod + def build(cls, **kwargs: Any) -> PersonalRecord: + if "user" in kwargs: + user = kwargs.pop("user") + kwargs.setdefault("user_id", user.id) + elif "user_id" not in kwargs: + # Will be created on create_sync call + user = UserFactory.create_sync() + kwargs["user_id"] = user.id + kwargs.setdefault("id", uuid4()) + kwargs.setdefault("birth_date", None) + kwargs.setdefault("sex", None) + kwargs.setdefault("gender", None) + return PersonalRecord(**kwargs) + + +class DeveloperFactory(_Base): + __model__ = Developer -class ApiKeyFactory(BaseFactory): - """Factory for ApiKey model.""" + @classmethod + def build(cls, **kwargs: Any) -> Developer: + password = kwargs.pop("password", "test_password") + kwargs.setdefault("id", uuid4()) + kwargs.setdefault("created_at", datetime.now(timezone.utc)) + kwargs.setdefault("updated_at", datetime.now(timezone.utc)) + kwargs.setdefault("email", f"dev-{uuid4().hex[:8]}@test.local") + kwargs.setdefault("hashed_password", f"hashed_{password}") + return Developer(**kwargs) - class Meta: - model = ApiKey - id = LazyFunction(lambda: f"sk-{uuid4().hex[:32]}") - name = Sequence(lambda n: f"Test API Key {n}") - created_at = LazyFunction(lambda: datetime.now(timezone.utc)) +class ApiKeyFactory(_Base): + __model__ = ApiKey @classmethod - def _create(cls, model_class: type[ApiKey], *args: Any, **kwargs: Any) -> ApiKey: - """Override create to handle developer relationship.""" - developer = kwargs.pop("developer", None) - # Remove any stale created_by that might have been set - kwargs.pop("created_by", None) - if developer is None: - # Create a developer if not provided - developer = DeveloperFactory() - kwargs["created_by"] = developer.id - return super()._create(model_class, *args, **kwargs) - - -class ApplicationFactory(BaseFactory): - """Factory for Application model (SDK apps).""" - - class Meta: - model = Application - - id = LazyFunction(uuid4) - app_id = LazyFunction(lambda: f"app_{uuid4().hex[:32]}") - name = Sequence(lambda n: f"Test Application {n}") - created_at = LazyFunction(lambda: datetime.now(timezone.utc)) - updated_at = LazyFunction(lambda: datetime.now(timezone.utc)) + def build(cls, **kwargs: Any) -> ApiKey: + if "developer" in kwargs: + dev = kwargs.pop("developer") + kwargs.setdefault("created_by", dev.id) + elif "created_by" not in kwargs: + dev = DeveloperFactory.create_sync() + kwargs["created_by"] = dev.id + kwargs.setdefault("id", f"sk-{uuid4().hex[:32]}") + kwargs.setdefault("name", "Test API Key") + kwargs.setdefault("created_at", datetime.now(timezone.utc)) + return ApiKey(**kwargs) + + +class ApplicationFactory(_Base): + __model__ = Application @classmethod - def _create(cls, model_class: type[Application], *args: Any, **kwargs: Any) -> Application: - """Override create to handle developer relationship and password hashing.""" - developer = kwargs.pop("developer", None) - # Remove any stale developer_id that might have been set - kwargs.pop("developer_id", None) - if developer is None: - # Create a developer if not provided - developer = DeveloperFactory() - kwargs["developer_id"] = developer.id - - # Handle app_secret -> app_secret_hash conversion with real bcrypt - # Default to "test_app_secret" if not provided - app_secret = kwargs.pop("app_secret", "test_app_secret") - if "app_secret_hash" not in kwargs: - kwargs["app_secret_hash"] = get_password_hash(app_secret) + def build(cls, **kwargs: Any) -> Application: + if "developer" in kwargs: + dev = kwargs.pop("developer") + kwargs.setdefault("developer_id", dev.id) + elif "developer_id" not in kwargs: + dev = DeveloperFactory.create_sync() + kwargs["developer_id"] = dev.id - return super()._create(model_class, *args, **kwargs) + app_secret = kwargs.pop("app_secret", "test_app_secret") + kwargs.setdefault("app_secret_hash", f"hashed_{app_secret}") + kwargs.setdefault("id", uuid4()) + kwargs.setdefault("app_id", f"app_{uuid4().hex[:32]}") + kwargs.setdefault("name", "Test Application") + kwargs.setdefault("created_at", datetime.now(timezone.utc)) + kwargs.setdefault("updated_at", datetime.now(timezone.utc)) + return Application(**kwargs) -class DataSourceFactory(BaseFactory): - """Factory for DataSource model.""" +# ════════════════════════════════════════════════════════════════════════════ +# Data layer +# ════════════════════════════════════════════════════════════════════════════ - class Meta: - model = DataSource - id = LazyFunction(uuid4) - provider = ProviderName.APPLE - device_model = LazyFunction(lambda: f"TestDevice-{uuid4().hex[:8]}") - software_version = "1.0.0" - source = "apple_health_sdk" - device_type = "watch" +class DataSourceFactory(_Base): + __model__ = DataSource @classmethod - def _create( - cls, - model_class: type[DataSource], - *args: Any, - **kwargs: Any, - ) -> DataSource: - user = kwargs.pop("user", None) - kwargs.pop("user_id", None) - - if user is None: - user = UserFactory() - kwargs["user_id"] = user.id - - # Handle provider parameter (ProviderName enum -> enum value) - provider = kwargs.get("provider") - if provider is not None and isinstance(provider, str): - try: - kwargs["provider"] = ProviderName(provider) - except ValueError: - kwargs["provider"] = ProviderName.UNKNOWN - - return super()._create(model_class, *args, **kwargs) - - -# Backward-compatible alias for tests still using the old name -DataSourceFactory = DataSourceFactory - - -class UserConnectionFactory(BaseFactory): - """Factory for UserConnection model.""" - - class Meta: - model = UserConnection - - id = LazyFunction(uuid4) - provider = "garmin" - provider_user_id = LazyFunction(lambda: f"provider_{uuid4().hex[:8]}") - provider_username = factory.Faker("user_name") - access_token = LazyFunction(lambda: f"access_{uuid4().hex}") # Optional for SDK providers - refresh_token = LazyFunction(lambda: f"refresh_{uuid4().hex}") - token_expires_at = LazyFunction(lambda: datetime(2025, 12, 31, tzinfo=timezone.utc)) # Optional for SDK providers - scope = "read_all" - status = ConnectionStatus.ACTIVE - last_synced_at = None - created_at = LazyFunction(lambda: datetime.now(timezone.utc)) - updated_at = LazyFunction(lambda: datetime.now(timezone.utc)) + def build(cls, **kwargs: Any) -> DataSource: + if "user" in kwargs: + user = kwargs.pop("user") + kwargs.setdefault("user_id", user.id) + elif "user_id" not in kwargs: + user = UserFactory.create_sync() + kwargs["user_id"] = user.id + kwargs.setdefault("id", uuid4()) + kwargs.setdefault("provider", ProviderName.APPLE) + kwargs.setdefault("device_model", f"TestDevice-{uuid4().hex[:8]}") + kwargs.setdefault("software_version", "1.0.0") + kwargs.setdefault("source", "apple_health_sdk") + kwargs.setdefault("device_type", "watch") + kwargs.setdefault("user_connection_id", None) + return DataSource(**kwargs) + + +class UserConnectionFactory(_Base): + __model__ = UserConnection @classmethod - def _create(cls, model_class: type[UserConnection], *args: Any, **kwargs: Any) -> UserConnection: - """Override create to handle user relationship.""" - user = kwargs.pop("user", None) - # Remove any stale user_id that might have been set - kwargs.pop("user_id", None) - if user is None: - user = UserFactory() - kwargs["user_id"] = user.id - return super()._create(model_class, *args, **kwargs) - - -class EventRecordFactory(BaseFactory): - """Factory for EventRecord model.""" - - class Meta: - model = EventRecord - - id = LazyFunction(uuid4) - category = "workout" - type = "running" - source_name = "Apple Watch" - duration_seconds = 3600 - start_datetime = LazyFunction(lambda: datetime.now(timezone.utc)) - end_datetime = LazyAttribute( - lambda o: datetime.fromtimestamp(o.start_datetime.timestamp() + (o.duration_seconds or 3600), tz=timezone.utc), - ) + def build(cls, **kwargs: Any) -> UserConnection: + if "user" in kwargs: + user = kwargs.pop("user") + kwargs.setdefault("user_id", user.id) + elif "user_id" not in kwargs: + user = UserFactory.create_sync() + kwargs["user_id"] = user.id + kwargs.setdefault("id", uuid4()) + kwargs.setdefault("provider", "garmin") + kwargs.setdefault("provider_user_id", f"prov_{uuid4().hex[:8]}") + kwargs.setdefault("provider_username", "testuser") + kwargs.setdefault("access_token", f"access_{uuid4().hex}") + kwargs.setdefault("refresh_token", f"refresh_{uuid4().hex}") + kwargs.setdefault("token_expires_at", datetime(2027, 12, 31, tzinfo=timezone.utc)) + kwargs.setdefault("scope", "read_all") + kwargs.setdefault("status", ConnectionStatus.ACTIVE) + kwargs.setdefault("last_synced_at", None) + kwargs.setdefault("created_at", datetime.now(timezone.utc)) + kwargs.setdefault("updated_at", datetime.now(timezone.utc)) + return UserConnection(**kwargs) + + +# ════════════════════════════════════════════════════════════════════════════ +# Events & details +# ════════════════════════════════════════════════════════════════════════════ + + +class EventRecordFactory(_Base): + __model__ = EventRecord @classmethod - def _create(cls, model_class: type[EventRecord], *args: Any, **kwargs: Any) -> EventRecord: - """Override create to handle data_source relationship and type_ alias.""" - # Support both "mapping" (legacy) and "data_source" parameter names - data_source = kwargs.pop("data_source", None) or kwargs.pop("mapping", None) - # Remove any stale data_source_id that might have been set - kwargs.pop("data_source_id", None) - if data_source is None: - data_source = DataSourceFactory() - kwargs["data_source_id"] = data_source.id - - # Handle type_ alias + def build(cls, **kwargs: Any) -> EventRecord: + if "data_source" in kwargs: + ds = kwargs.pop("data_source") + kwargs.setdefault("data_source_id", ds.id) + elif "data_source_id" not in kwargs: + ds = DataSourceFactory.create_sync() + kwargs["data_source_id"] = ds.id + + # Handle type_ alias (SQLAlchemy reserves `type`) if "type_" in kwargs: kwargs["type"] = kwargs.pop("type_") - return super()._create(model_class, *args, **kwargs) + now = datetime.now(timezone.utc) + duration = kwargs.get("duration_seconds", 3600) + kwargs.setdefault("id", uuid4()) + kwargs.setdefault("category", "workout") + kwargs.setdefault("type", "running") + kwargs.setdefault("source_name", "Apple Watch") + kwargs.setdefault("duration_seconds", duration) + kwargs.setdefault("start_datetime", now) + kwargs.setdefault( + "end_datetime", + datetime.fromtimestamp( + kwargs["start_datetime"].timestamp() + (duration or 3600), + tz=timezone.utc, + ), + ) + return EventRecord(**kwargs) + + +class EventRecordDetailFactory(_Base): + __model__ = EventRecordDetail + @classmethod + def build(cls, **kwargs: Any) -> EventRecordDetail: + if "event_record" in kwargs: + er = kwargs.pop("event_record") + kwargs.setdefault("record_id", er.id) + elif "record_id" not in kwargs: + er = EventRecordFactory.create_sync() + kwargs["record_id"] = er.id + kwargs.setdefault("detail_type", "workout") + return EventRecordDetail(**kwargs) -class EventRecordDetailFactory(BaseFactory): - """Factory for EventRecordDetail model.""" - - class Meta: - model = EventRecordDetail - detail_type = "workout" +class WorkoutDetailsFactory(_Base): + __model__ = WorkoutDetails @classmethod - def _create( - cls, - model_class: type[EventRecordDetail], - *args: Any, - **kwargs: Any, - ) -> EventRecordDetail: - """Override create to handle event_record relationship.""" - event_record = kwargs.pop("event_record", None) - # Remove any stale record_id that might have been set - kwargs.pop("record_id", None) - if event_record is None: - event_record = EventRecordFactory() - kwargs["record_id"] = event_record.id - return super()._create(model_class, *args, **kwargs) - - -class DataPointSeriesFactory(BaseFactory): - """Factory for DataPointSeries model.""" - - class Meta: - model = DataPointSeries - - id = LazyFunction(uuid4) - value = LazyFunction(lambda: Decimal("72.0")) - recorded_at = LazyFunction(lambda: datetime.now(timezone.utc)) + def build(cls, **kwargs: Any) -> WorkoutDetails: + if "event_record" in kwargs: + er = kwargs.pop("event_record") + kwargs.setdefault("record_id", er.id) + elif "record_id" not in kwargs: + er = EventRecordFactory.create_sync(category="workout") + kwargs["record_id"] = er.id + kwargs.setdefault("detail_type", "workout") + kwargs.setdefault("heart_rate_avg", Decimal("145.5")) + kwargs.setdefault("heart_rate_max", 175) + kwargs.setdefault("heart_rate_min", 95) + kwargs.setdefault("steps_count", 8500) + return WorkoutDetails(**kwargs) + + +class SleepDetailsFactory(_Base): + __model__ = SleepDetails @classmethod - def _create(cls, model_class: type[DataPointSeries], *args: Any, **kwargs: Any) -> DataPointSeries: - """Override create to handle relationships.""" - # Support both "mapping" (legacy) and "data_source" parameter names - data_source = kwargs.pop("data_source", None) or kwargs.pop("mapping", None) - series_type = kwargs.pop("series_type", None) - - if data_source is None: - data_source = DataSourceFactory() - if series_type is None: - # Use the pre-seeded heart_rate series type - series_type = SeriesTypeDefinitionFactory.get_or_create_heart_rate() - - kwargs["data_source_id"] = data_source.id - kwargs["series_type_definition_id"] = series_type.id - - # Convert value to Decimal if needed - if "value" in kwargs and not isinstance(kwargs["value"], Decimal): - kwargs["value"] = Decimal(str(kwargs["value"])) - - return super()._create(model_class, *args, **kwargs) + def build(cls, **kwargs: Any) -> SleepDetails: + if "event_record" in kwargs: + er = kwargs.pop("event_record") + kwargs.setdefault("record_id", er.id) + elif "record_id" not in kwargs: + er = EventRecordFactory.create_sync(category="sleep", type="sleep") + kwargs["record_id"] = er.id + kwargs.setdefault("detail_type", "sleep") + kwargs.setdefault("sleep_total_duration_minutes", 480) + kwargs.setdefault("sleep_deep_minutes", 120) + kwargs.setdefault("sleep_light_minutes", 240) + kwargs.setdefault("sleep_rem_minutes", 90) + kwargs.setdefault("sleep_awake_minutes", 30) + return SleepDetails(**kwargs) + + +# ════════════════════════════════════════════════════════════════════════════ +# Time-series +# ════════════════════════════════════════════════════════════════════════════ + + +class DataPointSeriesFactory(_Base): + __model__ = DataPointSeries + @classmethod + def build(cls, **kwargs: Any) -> DataPointSeries: + if "data_source" in kwargs: + ds = kwargs.pop("data_source") + kwargs.setdefault("data_source_id", ds.id) + elif "data_source_id" not in kwargs: + ds = DataSourceFactory.create_sync() + kwargs["data_source_id"] = ds.id -class ProviderSettingFactory(BaseFactory): - """Factory for ProviderSetting model.""" + if "series_type" in kwargs: + st = kwargs.pop("series_type") + kwargs.setdefault("series_type_definition_id", st.id) + elif "series_type_definition_id" not in kwargs: + st = SeriesTypeDefinitionFactory.heart_rate() + kwargs["series_type_definition_id"] = st.id - class Meta: - model = ProviderSetting + value = kwargs.get("value", Decimal("72.0")) + if not isinstance(value, Decimal): + value = Decimal(str(value)) + kwargs["value"] = value - provider = "garmin" - is_enabled = True + kwargs.setdefault("id", uuid4()) + kwargs.setdefault("recorded_at", datetime.now(timezone.utc)) + return DataPointSeries(**kwargs) -class WorkoutDetailsFactory(BaseFactory): - """Factory for WorkoutDetails model.""" +# ════════════════════════════════════════════════════════════════════════════ +# Config / settings +# ════════════════════════════════════════════════════════════════════════════ - class Meta: - model = WorkoutDetails - heart_rate_avg = LazyFunction(lambda: Decimal("145.5")) - heart_rate_max = 175 - heart_rate_min = 95 - steps_count = 8500 +class ProviderSettingFactory(_Base): + __model__ = ProviderSetting @classmethod - def _create(cls, model_class: type[WorkoutDetails], *args: Any, **kwargs: Any) -> WorkoutDetails: - """Override create to handle event_record relationship.""" - event_record = kwargs.pop("event_record", None) - # Remove any stale record_id that might have been set - kwargs.pop("record_id", None) - if event_record is None: - event_record = EventRecordFactory(category="workout") - kwargs["record_id"] = event_record.id - - # Convert heart_rate_avg to Decimal if needed - if "heart_rate_avg" in kwargs and not isinstance(kwargs["heart_rate_avg"], Decimal): - kwargs["heart_rate_avg"] = Decimal(str(kwargs["heart_rate_avg"])) + def build(cls, **kwargs: Any) -> ProviderSetting: + kwargs.setdefault("provider", "garmin") + kwargs.setdefault("is_enabled", True) + return ProviderSetting(**kwargs) - return super()._create(model_class, *args, **kwargs) - - -class SleepDetailsFactory(BaseFactory): - """Factory for SleepDetails model.""" - - class Meta: - model = SleepDetails - - sleep_total_duration_minutes = 480 # 8 hours - sleep_deep_minutes = 120 # 2 hours - sleep_light_minutes = 240 # 4 hours - sleep_rem_minutes = 90 # 1.5 hours - sleep_awake_minutes = 30 # 30 min - - @classmethod - def _create(cls, model_class: type[SleepDetails], *args: Any, **kwargs: Any) -> SleepDetails: - """Override create to handle event_record relationship.""" - event_record = kwargs.pop("event_record", None) - # Remove any stale record_id that might have been set - kwargs.pop("record_id", None) - if event_record is None: - event_record = EventRecordFactory(category="sleep", type="sleep") - kwargs["record_id"] = event_record.id - return super()._create(model_class, *args, **kwargs) +# ── Public API ────────────────────────────────────────────────────────────── __all__ = [ - "BaseFactory", + "set_session", + "clear_session", "SeriesTypeDefinitionFactory", "UserFactory", + "PersonalRecordFactory", "DeveloperFactory", "ApiKeyFactory", "ApplicationFactory", "DataSourceFactory", - "DataSourceFactory", # Backward-compatible alias for DataSourceFactory "UserConnectionFactory", "EventRecordFactory", "EventRecordDetailFactory", - "DataPointSeriesFactory", - "ProviderSettingFactory", "WorkoutDetailsFactory", "SleepDetailsFactory", + "DataPointSeriesFactory", + "ProviderSettingFactory", ] diff --git a/backend/tests/integrations/__init__.py b/backend/tests/integrations/__init__.py index a2650482..e69de29b 100644 --- a/backend/tests/integrations/__init__.py +++ b/backend/tests/integrations/__init__.py @@ -1 +0,0 @@ -# Integration tests package diff --git a/backend/tests/providers/conftest.py b/backend/tests/providers/conftest.py index e6418db9..00fb8486 100644 --- a/backend/tests/providers/conftest.py +++ b/backend/tests/providers/conftest.py @@ -1,7 +1,8 @@ """ Provider-specific test fixtures. -These fixtures provide mock data and utilities for testing provider integrations. +Sample payloads for Garmin, Polar, Suunto, etc., used across provider and +integration tests. """ from unittest.mock import MagicMock @@ -11,17 +12,19 @@ @pytest.fixture def mock_httpx_response() -> MagicMock: - """Mock httpx response for provider API calls.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {} - mock_response.raise_for_status.return_value = None - return mock_response + """Generic 200 httpx response stub.""" + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = {} + resp.raise_for_status.return_value = None + return resp + + +# ── Garmin ────────────────────────────────────────────────────────────────── @pytest.fixture def sample_garmin_activity() -> dict: - """Sample Garmin activity JSON data.""" return { "activityId": 12345678901, "activityName": "Morning Run", @@ -39,155 +42,18 @@ def sample_garmin_activity() -> dict: @pytest.fixture def sample_garmin_heart_rate_samples() -> list[dict]: - """Sample Garmin heart rate time series data.""" - return [ - {"startTimeGMT": "2024-01-15T07:00:00", "heartRate": 120}, - {"startTimeGMT": "2024-01-15T07:01:00", "heartRate": 135}, - {"startTimeGMT": "2024-01-15T07:02:00", "heartRate": 145}, - {"startTimeGMT": "2024-01-15T07:03:00", "heartRate": 150}, - {"startTimeGMT": "2024-01-15T07:04:00", "heartRate": 155}, - ] + return [{"startTimeGMT": "2024-01-15T07:00:00", "heartRate": hr} for hr in (120, 135, 145, 150, 155)] + + +# ── Polar ─────────────────────────────────────────────────────────────────── @pytest.fixture def sample_polar_exercise() -> dict: - """Sample Polar exercise JSON data.""" return { "id": "ABC123", "upload_time": "2024-01-15T09:00:00.000Z", "polar_user": "https://www.polaraccesslink.com/v3/users/12345", "transaction_id": 67890, "device": "Polar Vantage V2", - "device_id": "12345678", - "start_time": "2024-01-15T08:00:00", - "start_time_utc_offset": 60, - "duration": "PT1H0M0S", - "calories": 650, - "distance": 10000, - "heart_rate": { - "average": 145, - "maximum": 175, - }, - "training_load": 150.0, - "sport": "RUNNING", - "has_route": True, - "detailed_sport_info": "RUNNING", - } - - -@pytest.fixture -def sample_polar_heart_rate_zones() -> dict: - """Sample Polar heart rate zones data.""" - return { - "zone_1": {"lower_limit": 93, "upper_limit": 111, "in_zone": "PT10M"}, - "zone_2": {"lower_limit": 111, "upper_limit": 130, "in_zone": "PT15M"}, - "zone_3": {"lower_limit": 130, "upper_limit": 149, "in_zone": "PT20M"}, - "zone_4": {"lower_limit": 149, "upper_limit": 167, "in_zone": "PT10M"}, - "zone_5": {"lower_limit": 167, "upper_limit": 186, "in_zone": "PT5M"}, - } - - -@pytest.fixture -def sample_suunto_workout() -> dict: - """Sample Suunto workout JSON data.""" - return { - "workoutKey": "suunto-workout-123", - "activityId": 1, - "workoutName": "Morning Run", - "startTime": 1705309200000, # 2024-01-15T08:00:00 in milliseconds - "totalTime": 3600000, # 1 hour in milliseconds - "totalDistance": 10000.0, - "totalAscent": 150.0, - "totalDescent": 140.0, - "maxSpeed": 15.0, - "avgSpeed": 10.0, - "avgHR": 145, - "maxHR": 175, - "avgCadence": 85, - "totalCalories": 650, - } - - -@pytest.fixture -def sample_suunto_samples() -> dict: - """Sample Suunto workout samples data.""" - return { - "Samples": [ - {"TimeISO8601": "2024-01-15T08:00:00Z", "HR": 120}, - {"TimeISO8601": "2024-01-15T08:01:00Z", "HR": 135}, - {"TimeISO8601": "2024-01-15T08:02:00Z", "HR": 145}, - ], - } - - -@pytest.fixture -def sample_apple_auto_export_workout() -> dict: - """Sample Apple Auto Export workout JSON data.""" - return { - "id": "apple-workout-123", - "name": "Running", - "start": "2024-01-15T08:00:00-05:00", - "end": "2024-01-15T09:00:00-05:00", - "duration": 3600.0, - "distance": {"qty": 10000.0, "units": "m"}, - "activeEnergy": {"qty": 650.0, "units": "kcal"}, - "heartRateData": [ - {"date": "2024-01-15T08:00:00-05:00", "qty": 120.0}, - {"date": "2024-01-15T08:01:00-05:00", "qty": 135.0}, - {"date": "2024-01-15T08:02:00-05:00", "qty": 145.0}, - ], - "stepCount": [ - {"date": "2024-01-15T08:00:00-05:00", "qty": 100.0}, - {"date": "2024-01-15T08:01:00-05:00", "qty": 95.0}, - ], - } - - -@pytest.fixture -def sample_apple_healthkit_workout() -> dict: - """Sample Apple HealthKit workout JSON data.""" - return { - "uuid": "12345678-1234-1234-1234-123456789012", - "workoutActivityType": "HKWorkoutActivityTypeRunning", - "duration": 3600.0, - "totalDistance": 10000.0, - "totalEnergyBurned": 650.0, - "startDate": "2024-01-15T08:00:00-05:00", - "endDate": "2024-01-15T09:00:00-05:00", - "sourceName": "Apple Watch", - "sourceVersion": "10.0", - "device": "Apple Watch Series 9", - } - - -@pytest.fixture -def mock_oauth_token_response() -> dict: - """Mock OAuth token exchange response.""" - return { - "access_token": "test_access_token_abc123", - "refresh_token": "test_refresh_token_xyz789", - "expires_in": 3600, - "token_type": "Bearer", - "scope": "activity:read profile:read", - } - - -@pytest.fixture -def mock_oauth_refresh_response() -> dict: - """Mock OAuth token refresh response.""" - return { - "access_token": "new_access_token_def456", - "refresh_token": "new_refresh_token_uvw123", - "expires_in": 3600, - "token_type": "Bearer", - } - - -@pytest.fixture -def mock_provider_user_info() -> dict: - """Mock provider user info response.""" - return { - "user_id": "provider_user_12345", - "username": "test_user", - "email": "test@example.com", } diff --git a/backend/tests/repositories/__init__.py b/backend/tests/repositories/__init__.py index a2215367..e69de29b 100644 --- a/backend/tests/repositories/__init__.py +++ b/backend/tests/repositories/__init__.py @@ -1 +0,0 @@ -# Repository tests package diff --git a/backend/tests/services/__init__.py b/backend/tests/services/__init__.py index 53a6576f..e69de29b 100644 --- a/backend/tests/services/__init__.py +++ b/backend/tests/services/__init__.py @@ -1 +0,0 @@ -# Service tests package diff --git a/backend/tests/tasks/__init__.py b/backend/tests/tasks/__init__.py index 59b99040..e69de29b 100644 --- a/backend/tests/tasks/__init__.py +++ b/backend/tests/tasks/__init__.py @@ -1 +0,0 @@ -# Celery task tests package diff --git a/backend/tests/tasks/conftest.py b/backend/tests/tasks/conftest.py index f46e6733..c27800bf 100644 --- a/backend/tests/tasks/conftest.py +++ b/backend/tests/tasks/conftest.py @@ -1,7 +1,5 @@ """ -Pytest configuration for Celery task tests. - -Provides fixtures specific to testing asynchronous tasks. +Celery task test fixtures. """ from collections.abc import Generator @@ -14,12 +12,7 @@ @pytest.fixture def mock_celery_app() -> Generator[MagicMock, None, None]: - """ - Configure Celery for synchronous test execution. - - Sets up Celery to run tasks eagerly (synchronously) during tests - and propagate exceptions immediately. - """ + """Synchronous (eager) Celery app.""" with patch("celery.current_app") as mock: mock.conf = { "task_always_eager": True, @@ -30,17 +23,12 @@ def mock_celery_app() -> Generator[MagicMock, None, None]: @pytest.fixture def mock_session_local() -> Callable[[Session], MagicMock]: - """ - Mock SessionLocal for Celery tasks that create their own sessions. - - Returns a context manager that yields the test database session. - """ + """Context-manager mock for ``SessionLocal`` in task code.""" - def _mock_session_context(db: Session) -> MagicMock: - """Create a mock SessionLocal context manager.""" - mock = MagicMock() - mock.__enter__ = MagicMock(return_value=db) - mock.__exit__ = MagicMock(return_value=None) - return mock + def _ctx(db: Session) -> MagicMock: + cm = MagicMock() + cm.__enter__ = MagicMock(return_value=db) + cm.__exit__ = MagicMock(return_value=None) + return cm - return _mock_session_context + return _ctx diff --git a/backend/tests/test_smoke.py b/backend/tests/test_smoke.py new file mode 100644 index 00000000..eb76f9b9 --- /dev/null +++ b/backend/tests/test_smoke.py @@ -0,0 +1,19 @@ +""" +Smoke test — validates the testcontainers + polyfactory infrastructure. + +Remove this file once real tests are in place. +""" + +from sqlalchemy.orm import Session + +from app.models import User +from tests.factories import UserFactory + + +def test_factory_creates_user(db: Session) -> None: + user = UserFactory.create_sync(email="smoke@test.local") + assert user.id is not None + assert user.email == "smoke@test.local" + found = db.get(User, user.id) + assert found is not None + assert found.email == "smoke@test.local" diff --git a/backend/tests/utils/__init__.py b/backend/tests/utils/__init__.py index a2ca230a..e69de29b 100644 --- a/backend/tests/utils/__init__.py +++ b/backend/tests/utils/__init__.py @@ -1,9 +0,0 @@ -# Test utilities package -from .auth import api_key_headers, create_test_token, developer_auth_headers - -__all__ = [ - # Auth helpers - "developer_auth_headers", - "api_key_headers", - "create_test_token", -] diff --git a/backend/tests/utils/auth.py b/backend/tests/utils/auth.py index 55ef3ef2..37381ec1 100644 --- a/backend/tests/utils/auth.py +++ b/backend/tests/utils/auth.py @@ -14,9 +14,9 @@ def developer_auth_headers(developer_id: UUID | str) -> dict[str, str]: return {"Authorization": f"Bearer {token}"} -def api_key_headers(api_key: str) -> dict[str, str]: - """Generate X-Open-Wearables-API-Key headers for API key authentication.""" - return {"X-Open-Wearables-API-Key": api_key} +def api_key_headers(api_key_id: str) -> dict[str, str]: + """Generate X-Open-Wearables-API-Key headers.""" + return {"X-Open-Wearables-API-Key": api_key_id} def create_test_token( diff --git a/backend/uv.lock b/backend/uv.lock index 9e0be945..7ca5f7d3 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -479,6 +479,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + [[package]] name = "ecdsa" version = "0.19.1" @@ -513,18 +527,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] -[[package]] -name = "factory-boy" -version = "3.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "faker" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ba/98/75cacae9945f67cfe323829fc2ac451f64517a8a330b572a06a323997065/factory_boy-3.3.3.tar.gz", hash = "sha256:866862d226128dfac7f2b4160287e899daf54f2612778327dd03d0e2cb1e3d03", size = 164146, upload-time = "2025-02-03T09:49:04.433Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/8d/2bc5f5546ff2ccb3f7de06742853483ab75bf74f36a92254702f8baecc79/factory_boy-3.3.3-py2.py3-none-any.whl", hash = "sha256:1c39e3289f7e667c4285433f305f8d506efc2fe9c73aaea4151ebd5cdea394fc", size = 37036, upload-time = "2025-02-03T09:49:01.659Z" }, -] - [[package]] name = "faker" version = "38.2.0" @@ -888,15 +890,15 @@ code-quality = [ { name = "ty" }, ] dev = [ - { name = "factory-boy" }, - { name = "faker" }, { name = "freezegun" }, + { name = "polyfactory" }, { name = "psycopg-binary" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "ruff" }, { name = "setuptools" }, + { name = "testcontainers" }, ] [package.metadata] @@ -931,15 +933,15 @@ code-quality = [ { name = "ty", specifier = ">=0.0.14" }, ] dev = [ - { name = "factory-boy", specifier = ">=3.3.0" }, - { name = "faker", specifier = ">=33.1.0" }, { name = "freezegun", specifier = ">=1.5.0" }, + { name = "polyfactory", specifier = ">=3.2.0" }, { name = "psycopg-binary", specifier = ">=3.3.2" }, { name = "pytest", specifier = ">=8.4.1" }, { name = "pytest-asyncio", specifier = ">=1.0.0" }, { name = "pytest-cov", specifier = ">=6.2.1" }, { name = "ruff", specifier = ">=0.14.7" }, { name = "setuptools", specifier = ">=75.0.0" }, + { name = "testcontainers", extras = ["postgres"], specifier = ">=4.14.1" }, ] [[package]] @@ -969,6 +971,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "polyfactory" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "faker" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/92/e90639b1d2abe982749eba7e734571a343ea062f7d486498b1c2b852f019/polyfactory-3.2.0.tar.gz", hash = "sha256:879242f55208f023eee1de48522de5cb1f9fd2d09b2314e999a9592829d596d1", size = 346878, upload-time = "2025-12-21T11:18:51.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/21/93363d7b802aa904f8d4169bc33e0e316d06d26ee68d40fe0355057da98c/polyfactory-3.2.0-py3-none-any.whl", hash = "sha256:5945799cce4c56cd44ccad96fb0352996914553cc3efaa5a286930599f569571", size = 62181, upload-time = "2025-12-21T11:18:49.311Z" }, +] + [[package]] name = "pre-commit" version = "4.5.1" @@ -1256,6 +1271,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1484,6 +1512,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, ] +[[package]] +name = "testcontainers" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docker" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/02/ef62dec9e4f804189c44df23f0b86897c738d38e9c48282fcd410308632f/testcontainers-4.14.1.tar.gz", hash = "sha256:316f1bb178d829c003acd650233e3ff3c59a833a08d8661c074f58a4fbd42a64", size = 80148, upload-time = "2026-01-31T23:13:46.915Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/31/5e7b23f9e43ff7fd46d243808d70c5e8daf3bc08ecf5a7fb84d5e38f7603/testcontainers-4.14.1-py3-none-any.whl", hash = "sha256:03dfef4797b31c82e7b762a454b6afec61a2a512ad54af47ab41e4fa5415f891", size = 125640, upload-time = "2026-01-31T23:13:45.464Z" }, +] + [[package]] name = "tornado" version = "6.5.2" @@ -1751,3 +1795,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] + +[[package]] +name = "wrapt" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/37/ae31f40bec90de2f88d9597d0b5281e23ffe85b893a47ca5d9c05c63a4f6/wrapt-2.1.1.tar.gz", hash = "sha256:5fdcb09bf6db023d88f312bd0767594b414655d58090fc1c46b3414415f67fac", size = 81329, upload-time = "2026-02-03T02:12:13.786Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ca/3cf290212855b19af9fcc41b725b5620b32f470d6aad970c2593500817eb/wrapt-2.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ce9646e17fa7c3e2e7a87e696c7de66512c2b4f789a8db95c613588985a2e139", size = 61150, upload-time = "2026-02-03T02:12:50.575Z" }, + { url = "https://files.pythonhosted.org/packages/9d/33/5b8f89a82a9859ce82da4870c799ad11ce15648b6e1c820fec3e23f4a19f/wrapt-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:428cfc801925454395aa468ba7ddb3ed63dc0d881df7b81626cdd433b4e2b11b", size = 61743, upload-time = "2026-02-03T02:11:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2f/60c51304fbdf47ce992d9eefa61fbd2c0e64feee60aaa439baf42ea6f40b/wrapt-2.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5797f65e4d58065a49088c3b32af5410751cd485e83ba89e5a45e2aa8905af98", size = 121341, upload-time = "2026-02-03T02:11:20.461Z" }, + { url = "https://files.pythonhosted.org/packages/ad/03/ce5256e66dd94e521ad5e753c78185c01b6eddbed3147be541f4d38c0cb7/wrapt-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a2db44a71202c5ae4bb5f27c6d3afbc5b23053f2e7e78aa29704541b5dad789", size = 122947, upload-time = "2026-02-03T02:11:33.596Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/50ca8854b81b946a11a36fcd6ead32336e6db2c14b6e4a8b092b80741178/wrapt-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8d5350c3590af09c1703dd60ec78a7370c0186e11eaafb9dda025a30eee6492d", size = 121370, upload-time = "2026-02-03T02:11:09.886Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d9/d6a7c654e0043319b4cc137a4caaf7aa16b46b51ee8df98d1060254705b7/wrapt-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d9b076411bed964e752c01b49fd224cc385f3a96f520c797d38412d70d08359", size = 120465, upload-time = "2026-02-03T02:11:37.592Z" }, + { url = "https://files.pythonhosted.org/packages/55/90/65be41e40845d951f714b5a77e84f377a3787b1e8eee6555a680da6d0db5/wrapt-2.1.1-cp313-cp313-win32.whl", hash = "sha256:0bb7207130ce6486727baa85373503bf3334cc28016f6928a0fa7e19d7ecdc06", size = 58090, upload-time = "2026-02-03T02:12:53.342Z" }, + { url = "https://files.pythonhosted.org/packages/5f/66/6a09e0294c4fc8c26028a03a15191721c9271672467cc33e6617ee0d91d2/wrapt-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:cbfee35c711046b15147b0ae7db9b976f01c9520e6636d992cd9e69e5e2b03b1", size = 60341, upload-time = "2026-02-03T02:12:36.384Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f0/20ceb8b701e9a71555c87a5ddecbed76ec16742cf1e4b87bbaf26735f998/wrapt-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:7d2756061022aebbf57ba14af9c16e8044e055c22d38de7bf40d92b565ecd2b0", size = 58731, upload-time = "2026-02-03T02:12:01.328Z" }, + { url = "https://files.pythonhosted.org/packages/80/b4/fe95beb8946700b3db371f6ce25115217e7075ca063663b8cca2888ba55c/wrapt-2.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4814a3e58bc6971e46baa910ecee69699110a2bf06c201e24277c65115a20c20", size = 62969, upload-time = "2026-02-03T02:11:51.245Z" }, + { url = "https://files.pythonhosted.org/packages/b8/89/477b0bdc784e3299edf69c279697372b8bd4c31d9c6966eae405442899df/wrapt-2.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:106c5123232ab9b9f4903692e1fa0bdc231510098f04c13c3081f8ad71c3d612", size = 63606, upload-time = "2026-02-03T02:12:02.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/55/9d0c1269ab76de87715b3b905df54dd25d55bbffd0b98696893eb613469f/wrapt-2.1.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1a40b83ff2535e6e56f190aff123821eea89a24c589f7af33413b9c19eb2c738", size = 152536, upload-time = "2026-02-03T02:11:24.492Z" }, + { url = "https://files.pythonhosted.org/packages/44/18/2004766030462f79ad86efaa62000b5e39b1ff001dcce86650e1625f40ae/wrapt-2.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:789cea26e740d71cf1882e3a42bb29052bc4ada15770c90072cb47bf73fb3dbf", size = 158697, upload-time = "2026-02-03T02:12:32.214Z" }, + { url = "https://files.pythonhosted.org/packages/e1/bb/0a880fa0f35e94ee843df4ee4dd52a699c9263f36881311cfb412c09c3e5/wrapt-2.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ba49c14222d5e5c0ee394495a8655e991dc06cbca5398153aefa5ac08cd6ccd7", size = 155563, upload-time = "2026-02-03T02:11:49.737Z" }, + { url = "https://files.pythonhosted.org/packages/42/ff/cd1b7c4846c8678fac359a6eb975dc7ab5bd606030adb22acc8b4a9f53f1/wrapt-2.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ac8cda531fe55be838a17c62c806824472bb962b3afa47ecbd59b27b78496f4e", size = 150161, upload-time = "2026-02-03T02:12:33.613Z" }, + { url = "https://files.pythonhosted.org/packages/38/ec/67c90a7082f452964b4621e4890e9a490f1add23cdeb7483cc1706743291/wrapt-2.1.1-cp313-cp313t-win32.whl", hash = "sha256:b8af75fe20d381dd5bcc9db2e86a86d7fcfbf615383a7147b85da97c1182225b", size = 59783, upload-time = "2026-02-03T02:11:39.863Z" }, + { url = "https://files.pythonhosted.org/packages/ec/08/466afe4855847d8febdfa2c57c87e991fc5820afbdef01a273683dfd15a0/wrapt-2.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:45c5631c9b6c792b78be2d7352129f776dd72c605be2c3a4e9be346be8376d83", size = 63082, upload-time = "2026-02-03T02:12:09.075Z" }, + { url = "https://files.pythonhosted.org/packages/9a/62/60b629463c28b15b1eeadb3a0691e17568622b12aa5bfa7ebe9b514bfbeb/wrapt-2.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:da815b9263947ac98d088b6414ac83507809a1d385e4632d9489867228d6d81c", size = 60251, upload-time = "2026-02-03T02:11:21.794Z" }, + { url = "https://files.pythonhosted.org/packages/95/a0/1c2396e272f91efe6b16a6a8bce7ad53856c8f9ae4f34ceaa711d63ec9e1/wrapt-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aa1765054245bb01a37f615503290d4e207e3fd59226e78341afb587e9c1236", size = 61311, upload-time = "2026-02-03T02:12:44.41Z" }, + { url = "https://files.pythonhosted.org/packages/b0/9a/d2faba7e61072a7507b5722db63562fdb22f5a24e237d460d18755627f15/wrapt-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:feff14b63a6d86c1eee33a57f77573649f2550935981625be7ff3cb7342efe05", size = 61805, upload-time = "2026-02-03T02:11:59.905Z" }, + { url = "https://files.pythonhosted.org/packages/db/56/073989deb4b5d7d6e7ea424476a4ae4bda02140f2dbeaafb14ba4864dd60/wrapt-2.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81fc5f22d5fcfdbabde96bb3f5379b9f4476d05c6d524d7259dc5dfb501d3281", size = 120308, upload-time = "2026-02-03T02:12:04.46Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b6/84f37261295e38167a29eb82affaf1dc15948dc416925fe2091beee8e4ac/wrapt-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:951b228ecf66def855d22e006ab9a1fc12535111ae7db2ec576c728f8ddb39e8", size = 122688, upload-time = "2026-02-03T02:11:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/ea/80/32db2eec6671f80c65b7ff175be61bc73d7f5223f6910b0c921bbc4bd11c/wrapt-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ddf582a95641b9a8c8bd643e83f34ecbbfe1b68bc3850093605e469ab680ae3", size = 121115, upload-time = "2026-02-03T02:12:39.068Z" }, + { url = "https://files.pythonhosted.org/packages/49/ef/dcd00383df0cd696614127902153bf067971a5aabcd3c9dcb2d8ef354b2a/wrapt-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fc5c500966bf48913f795f1984704e6d452ba2414207b15e1f8c339a059d5b16", size = 119484, upload-time = "2026-02-03T02:11:48.419Z" }, + { url = "https://files.pythonhosted.org/packages/76/29/0630280cdd2bd8f86f35cb6854abee1c9d6d1a28a0c6b6417cd15d378325/wrapt-2.1.1-cp314-cp314-win32.whl", hash = "sha256:4aa4baadb1f94b71151b8e44a0c044f6af37396c3b8bcd474b78b49e2130a23b", size = 58514, upload-time = "2026-02-03T02:11:58.616Z" }, + { url = "https://files.pythonhosted.org/packages/db/19/5bed84f9089ed2065f6aeda5dfc4f043743f642bc871454b261c3d7d322b/wrapt-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:860e9d3fd81816a9f4e40812f28be4439ab01f260603c749d14be3c0a1170d19", size = 60763, upload-time = "2026-02-03T02:12:24.553Z" }, + { url = "https://files.pythonhosted.org/packages/e4/cb/b967f2f9669e4249b4fe82e630d2a01bc6b9e362b9b12ed91bbe23ae8df4/wrapt-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3c59e103017a2c1ea0ddf589cbefd63f91081d7ce9d491d69ff2512bb1157e23", size = 59051, upload-time = "2026-02-03T02:11:29.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/19/6fed62be29f97eb8a56aff236c3f960a4b4a86e8379dc7046a8005901a97/wrapt-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9fa7c7e1bee9278fc4f5dd8275bc8d25493281a8ec6c61959e37cc46acf02007", size = 63059, upload-time = "2026-02-03T02:12:06.368Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1c/b757fd0adb53d91547ed8fad76ba14a5932d83dde4c994846a2804596378/wrapt-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39c35e12e8215628984248bd9c8897ce0a474be2a773db207eb93414219d8469", size = 63618, upload-time = "2026-02-03T02:12:23.197Z" }, + { url = "https://files.pythonhosted.org/packages/10/fe/e5ae17b1480957c7988d991b93df9f2425fc51f128cf88144d6a18d0eb12/wrapt-2.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:94ded4540cac9125eaa8ddf5f651a7ec0da6f5b9f248fe0347b597098f8ec14c", size = 152544, upload-time = "2026-02-03T02:11:43.915Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cc/99aed210c6b547b8a6e4cb9d1425e4466727158a6aeb833aa7997e9e08dd/wrapt-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da0af328373f97ed9bdfea24549ac1b944096a5a71b30e41c9b8b53ab3eec04a", size = 158700, upload-time = "2026-02-03T02:12:30.684Z" }, + { url = "https://files.pythonhosted.org/packages/81/0e/d442f745f4957944d5f8ad38bc3a96620bfff3562533b87e486e979f3d99/wrapt-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4ad839b55f0bf235f8e337ce060572d7a06592592f600f3a3029168e838469d3", size = 155561, upload-time = "2026-02-03T02:11:28.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/ac/9891816280e0018c48f8dfd61b136af7b0dcb4a088895db2531acde5631b/wrapt-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d89c49356e5e2a50fa86b40e0510082abcd0530f926cbd71cf25bee6b9d82d7", size = 150188, upload-time = "2026-02-03T02:11:57.053Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/e2f273b6d70d41f98d0739aa9a269d0b633684a5fb17b9229709375748d4/wrapt-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:f4c7dd22cf7f36aafe772f3d88656559205c3af1b7900adfccb70edeb0d2abc4", size = 60425, upload-time = "2026-02-03T02:11:35.007Z" }, + { url = "https://files.pythonhosted.org/packages/1e/06/b500bfc38a4f82d89f34a13069e748c82c5430d365d9e6b75afb3ab74457/wrapt-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f76bc12c583ab01e73ba0ea585465a41e48d968f6d1311b4daec4f8654e356e3", size = 63855, upload-time = "2026-02-03T02:12:15.47Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cc/5f6193c32166faee1d2a613f278608e6f3b95b96589d020f0088459c46c9/wrapt-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7ea74fc0bec172f1ae5f3505b6655c541786a5cabe4bbc0d9723a56ac32eb9b9", size = 60443, upload-time = "2026-02-03T02:11:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/c4/da/5a086bf4c22a41995312db104ec2ffeee2cf6accca9faaee5315c790377d/wrapt-2.1.1-py3-none-any.whl", hash = "sha256:3b0f4629eb954394a3d7c7a1c8cca25f0b07cefe6aa8545e862e9778152de5b7", size = 43886, upload-time = "2026-02-03T02:11:45.048Z" }, +] diff --git a/contributing/testing.md b/contributing/testing.md index 4610cfda..7e9c5794 100644 --- a/contributing/testing.md +++ b/contributing/testing.md @@ -4,35 +4,9 @@ This guide covers how to run tests and write new tests for Open Wearables. ## Prerequisites -Before running tests, you need a PostgreSQL database running: - -### Option 1: Use Docker (Recommended) - -```bash -# Start only the PostgreSQL container -docker compose up db -d - -# Wait for it to be ready -docker compose logs -f db # Look for "database system is ready" - -# Create the test database -docker compose exec db psql -U open-wearables -c "CREATE DATABASE open_wearables_test;" -``` - -### Option 2: Local PostgreSQL - -If you have PostgreSQL installed locally: - -```bash -createdb -U open-wearables open_wearables_test -``` - -**Test Database Configuration:** -- Host: `localhost` -- Port: `5432` -- Database: `open_wearables_test` -- User: `open-wearables` -- Password: `open-wearables` +- **Docker daemon** must be running (testcontainers spins up a disposable Postgres automatically). +- `uv` installed. +- Backend dev dependencies: `cd backend && uv sync --dev` ## Running Tests