diff --git a/.github/WORKFLOWS.md b/.github/WORKFLOWS.md index c6ce02b..3160e83 100644 --- a/.github/WORKFLOWS.md +++ b/.github/WORKFLOWS.md @@ -18,6 +18,7 @@ GitHub Actions and CI/CD helpers for this repository (see [`.github/`](../.githu | [`workflows/release.yml`](workflows/release.yml) | **Release** — manual `workflow_dispatch` only; tags `main` from `pyproject.toml` (`v`) and creates a GitHub Release with Weblate compatibility metadata | | [`workflows/ci-lint.yml`](workflows/ci-lint.yml) | Lint and format (prek) | | [`workflows/ci-test.yml`](workflows/ci-test.yml) | Unit tests and coverage (Python **3.12**, **3.13**, **3.14** matrix; `fail-fast: false`) | +| [`workflows/ci-postgres.yml`](workflows/ci-postgres.yml) | DB-backed integration tests (`pytest -m postgres`; Python **3.12**; PostgreSQL service container) | | [`workflows/ci-benchmark.yml`](workflows/ci-benchmark.yml) | QuickBook parser benchmarks (`pytest-benchmark`; JSON artifact; regression gate vs `.benchmarks/`) | | [`workflows/ci-package.yml`](workflows/ci-package.yml) | Build and package checks | | [`workflows/ci-dependencies.yml`](workflows/ci-dependencies.yml) | Dependency and license audit | diff --git a/.github/workflows/ci-postgres.yml b/.github/workflows/ci-postgres.yml new file mode 100644 index 0000000..ef4b913 --- /dev/null +++ b/.github/workflows/ci-postgres.yml @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: 2026 Andrew Zhang +# +# SPDX-License-Identifier: BSL-1.0 + +name: PostgreSQL tests + +on: + workflow_call: + +permissions: + contents: read + +jobs: + postgres: + name: PostgreSQL tests (Python 3.12) + runs-on: ubuntu-latest + timeout-minutes: 15 + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: weblate + POSTGRES_PASSWORD: weblate + POSTGRES_DB: weblate + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 5s + --health-timeout 3s + --health-retries 10 + steps: + # actions/checkout v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + persist-credentials: false + # actions/setup-python v6.2.0 + - name: Setup Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: '3.12' + # astral-sh/setup-uv v8.1.0 + - uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 + with: + version: 0.11.12 + - name: Install apt dependencies (Weblate venv) + run: sudo ./.github/ci/apt-install + - name: Install dependencies (incl. dev) + run: uv sync --frozen --group dev + - name: Pytest postgres tier + env: + DJANGO_SETTINGS_MODULE: tests.django_integration_settings + CI_DB_HOST: 127.0.0.1 + CI_DB_USER: weblate + CI_DB_PASSWORD: weblate + CI_DB_NAME: weblate + run: > + uv run --group dev pytest -m postgres -v --tb=short --reuse-db diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91d956b..02c2ca4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,8 @@ jobs: uses: ./.github/workflows/ci-lint.yml test: uses: ./.github/workflows/ci-test.yml + postgres: + uses: ./.github/workflows/ci-postgres.yml benchmark: needs: [test] uses: ./.github/workflows/ci-benchmark.yml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e3c1203..ba7a3d9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,7 +42,7 @@ Hooks include Ruff (lint + format), YAML/TOML checks, REUSE license compliance, ## Unit tests -Default pytest excludes slow or environment-specific markers (`plugin`, `benchmark`, `fuzz`, `weblate_contract`) — see markers in [`pyproject.toml`](pyproject.toml): +Default pytest excludes slow or environment-specific markers (`plugin`, `benchmark`, `fuzz`, `weblate_contract`, `postgres`) — see markers in [`pyproject.toml`](pyproject.toml): ```bash pytest @@ -75,10 +75,32 @@ Open `htmlcov/index.html` locally to browse line coverage (`coverage.xml`, `html |-----------|---------------| | `tests/formats/` | Format adapters and registry | | `tests/utils/` | QuickBook parse/serialize logic | -| `tests/endpoint/` | HTTP API, serializers, services | +| `tests/endpoint/` | HTTP API, serializers, services (mock ORM) | +| `tests/postgres/` | DB-backed endpoint and service tests (`-m postgres`) | | `tests/plugin/` | Live Weblate Docker stack (see below) | -Add tests next to the code you touch. Keep `django.setup()`-friendly patterns; the bundled Django test settings intentionally avoid heavy DB or migration suites. +Add tests next to the code you touch. Unit tests use `tests.django_qbk_format_settings` (SQLite, no migrations). PostgreSQL tests use `tests.django_integration_settings` (PostgreSQL + migrations). + +### PostgreSQL tests + +The postgres tier verifies untrusted-input → serializer → ORM behavior against a migrated database without the full Weblate Docker stack. It is excluded from default `pytest` and pre-commit runs. + +Start PostgreSQL locally (same credentials as CI): + +```bash +docker run -d --name weblate-integration-pg -p 5432:5432 \ + -e POSTGRES_USER=weblate -e POSTGRES_PASSWORD=weblate -e POSTGRES_DB=weblate \ + postgres:16-alpine +``` + +Run PostgreSQL tests: + +```bash +export CI_DB_HOST=127.0.0.1 CI_DB_USER=weblate CI_DB_PASSWORD=weblate CI_DB_NAME=weblate +DJANGO_SETTINGS_MODULE=tests.django_integration_settings pytest -m postgres -v --reuse-db +``` + +CI runs this tier in [`.github/workflows/ci-postgres.yml`](.github/workflows/ci-postgres.yml) (Python 3.12, PostgreSQL service container). ## Plugin and Docker tests diff --git a/README.md b/README.md index 38ea079..c34f241 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ flowchart TB - **`src/boost_weblate/endpoint/`** — **HTTP API** for Boost documentation project/component management. Exposes three routes under `/boost-endpoint/` (see [Boost endpoint routes](#boost-endpoint-routes)), uses Django REST Framework for auth and serialization, and hands off heavy work to a Celery task (see [Celery requirement for add-or-update](#celery-requirement-for-add-or-update)). -- **`tests/`** — **Pytest** layout mirrors `src/boost_weblate/` (`tests/formats/`, `tests/utils/`, `tests/endpoint/`). Shared fixtures live under `tests/fixtures/`. `tests/conftest.py` configures `sys.path`, sets `DJANGO_SETTINGS_MODULE` to `tests.django_qbk_format_settings`, and calls `django.setup()` so format tests can load Weblate's Django stack without requiring PostgreSQL. Docker-based plugin tests live in `tests/plugin/`. +- **`tests/`** — **Pytest** layout mirrors `src/boost_weblate/` (`tests/formats/`, `tests/utils/`, `tests/endpoint/`). Shared fixtures live under `tests/fixtures/`. `tests/conftest.py` configures `sys.path` and Django bootstrap. Default unit tests use `tests.django_qbk_format_settings` (SQLite, no migrations). DB-backed PostgreSQL tests live in `tests/postgres/` (`-m postgres`). Docker-based plugin tests live in `tests/plugin/`. ## WEBLATE_FORMATS configuration @@ -284,12 +284,13 @@ The service has no plugin-owned models; it operates entirely through Weblate's D ### CI (`ci.yml`) -Triggered on push and PR to `main` and `develop`. Calls nine reusable sub-workflows: +Triggered on push and PR to `main` and `develop`. Calls ten reusable sub-workflows: | Job | Workflow | What it checks | |-----|----------|----------------| | `lint` | [`.github/workflows/ci-lint.yml`](.github/workflows/ci-lint.yml) | prek (Ruff, YAML/TOML, REUSE, actionlint, pytest) | | `test` | [`.github/workflows/ci-test.yml`](.github/workflows/ci-test.yml) | pytest + 90% coverage gate on Python **3.12**, **3.13**, and **3.14** (`--cov-fail-under=90`) | +| `postgres` | [`.github/workflows/ci-postgres.yml`](.github/workflows/ci-postgres.yml) | DB-backed tests (`pytest -m postgres`) on Python **3.12** with PostgreSQL service container | | `benchmark` | [`.github/workflows/ci-benchmark.yml`](.github/workflows/ci-benchmark.yml) | QuickBook parser benchmarks (`pytest-benchmark`); JSON artifact; optional regression gate vs `.benchmarks/` baseline | | `package` | [`.github/workflows/ci-package.yml`](.github/workflows/ci-package.yml) | `uv build`, twine, pydistcheck, pyroma, check-wheel-contents, check-manifest | | `dependencies` | [`.github/workflows/ci-dependencies.yml`](.github/workflows/ci-dependencies.yml) | pip-audit, liccheck, dependency review (on PRs) | @@ -324,6 +325,25 @@ Each deploy SSHes to `/opt/cppa-weblate-plugin`, pulls the branch, rebuilds with Full deployment and promotion procedure: [docs/deployment-runbook.md](docs/deployment-runbook.md) (staging, production, `PROMOTE_PAT`, rollback, release tagging). +### Running PostgreSQL tests locally + +PostgreSQL tests exercise the serializer → view → ORM path against a migrated database (no full Weblate Docker stack). Start PostgreSQL (credentials aligned with [`docker/docker-compose.ci.yml`](docker/docker-compose.ci.yml)): + +```bash +docker run -d --name weblate-integration-pg -p 5432:5432 \ + -e POSTGRES_USER=weblate -e POSTGRES_PASSWORD=weblate -e POSTGRES_DB=weblate \ + postgres:16-alpine +``` + +Then run the postgres tier (first run applies Weblate migrations; use `--reuse-db` on subsequent runs): + +```bash +export CI_DB_HOST=127.0.0.1 CI_DB_USER=weblate CI_DB_PASSWORD=weblate CI_DB_NAME=weblate +DJANGO_SETTINGS_MODULE=tests.django_integration_settings pytest -m postgres -v --reuse-db +``` + +Default `pytest` and pre-commit exclude the `postgres` marker. + ### Running plugin tests locally ```bash @@ -369,7 +389,7 @@ See **[CONTRIBUTING.md](CONTRIBUTING.md)** for end-to-end local setup (uv, prek, New maintainers should also read [docs/maintainer-handoff.md](docs/maintainer-handoff.md) for Weblate coupling points and the [first verified change walkthrough](docs/maintainer-handoff.md#first-verified-change-walkthrough). -**Quick reference:** install with `uv pip install -e '.[dev]'`, run hooks with `prek run --all-files`, run unit tests with `pytest`. CI enforces 90% coverage on `boost_weblate` and runs plugin smoke/auth/functional jobs against the Docker CI stack. Parser benchmark details and baseline refresh instructions are in [CONTRIBUTING.md](CONTRIBUTING.md#parser-benchmarks). +**Quick reference:** install with `uv pip install -e '.[dev]'`, run hooks with `prek run --all-files`, run unit tests with `pytest`. CI enforces 90% coverage on `boost_weblate`, runs PostgreSQL tests (`ci-postgres`), and runs plugin smoke/auth/functional jobs against the Docker CI stack. Parser benchmark details and baseline refresh instructions are in [CONTRIBUTING.md](CONTRIBUTING.md#parser-benchmarks). ## License diff --git a/pyproject.toml b/pyproject.toml index ea60f37..2c1a3ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,8 @@ dev = [ "coverage[toml]==7.14.1", "hypothesis==6.155.3", "pytest-benchmark==5.2.3", - "pytest-cov==7.1.0" + "pytest-cov==7.1.0", + "pytest-django==4.12.0" ] lint = [ {include-group = "pre-commit"} @@ -74,6 +75,7 @@ dev = [ "hypothesis==6.155.3", "prek==0.4.4", "pytest-cov==7.1.0", + "pytest-django==4.12.0", "pytest==9.0.3" ] @@ -137,9 +139,11 @@ level = "cautious" unauthorized_licenses = [] [tool.pytest.ini_options] -addopts = ["-m", "not plugin and not benchmark and not fuzz and not weblate_contract"] +DJANGO_SETTINGS_MODULE = "tests.django_qbk_format_settings" +addopts = ["-m", "not plugin and not benchmark and not fuzz and not weblate_contract and not postgres"] markers = [ "benchmark: parser performance benchmarks (slow; excluded from default test runs)", + "postgres: DB-backed tests requiring PostgreSQL (excluded from default runs)", "plugin: requires live Weblate stack (Docker Compose) and optional WEBLATE_API_TOKEN", "slow: long-running plugin integration test", "fuzz: property-based / fuzz tests (excluded from default test runs; pytest -m fuzz)", diff --git a/tests/conftest.py b/tests/conftest.py index 4bb482f..9598bd1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,9 +42,8 @@ def pytest_configure() -> None: profile = os.environ.get("HYPOTHESIS_PROFILE", "ci") hypothesis_settings.load_profile(profile) - # Always use bundled settings so a host ``DJANGO_SETTINGS_MODULE`` does not - # break collection or ``python tests/formats/test_quickbook.py``. - os.environ["DJANGO_SETTINGS_MODULE"] = "tests.django_qbk_format_settings" + # Default bundled settings for unit/format tests; integration runs override via env. + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.django_qbk_format_settings") import django from django.conf import settings diff --git a/tests/django_integration_settings.py b/tests/django_integration_settings.py new file mode 100644 index 0000000..75fb738 --- /dev/null +++ b/tests/django_integration_settings.py @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: 2026 Andrew Zhang +# +# SPDX-License-Identifier: BSL-1.0 + +"""Django settings for DB-backed integration tests. + +Extends Weblate's ``settings_test`` (PostgreSQL, eager Celery, full migrations) +with the Boost endpoint app and throttle configuration. + +Requires a PostgreSQL server. Configure via ``CI_DB_*`` environment variables +(same contract as upstream Weblate CI):: + + export CI_DB_HOST=127.0.0.1 + export CI_DB_USER=weblate + export CI_DB_PASSWORD=weblate + export CI_DB_NAME=weblate + DJANGO_SETTINGS_MODULE=tests.django_integration_settings pytest -m postgres +""" + +from __future__ import annotations + +from weblate.settings_test import * # noqa: F403 + +from boost_weblate.settings_override import merge_boost_endpoint_throttle_rates + +_ENDPOINT_APP_CONFIG = "boost_weblate.endpoint.apps.BoostEndpointConfig" + +REST_FRAMEWORK = merge_boost_endpoint_throttle_rates(REST_FRAMEWORK) # noqa: F405 + +if _ENDPOINT_APP_CONFIG not in INSTALLED_APPS: # noqa: F405 + INSTALLED_APPS += (_ENDPOINT_APP_CONFIG,) # noqa: F405 diff --git a/tests/endpoint/test_views.py b/tests/endpoint/test_views.py index 1293e97..8b48026 100644 --- a/tests/endpoint/test_views.py +++ b/tests/endpoint/test_views.py @@ -913,6 +913,8 @@ def process_all(self, _submodules, *, user, request=None): # noqa: ANN001 def test_get_or_create_project_uses_literal_lang_in_slug( self, monkeypatch: pytest.MonkeyPatch ) -> None: + from contextlib import nullcontext + from boost_weblate.endpoint import services as services_mod lang_code = "zh_Hans" @@ -927,6 +929,7 @@ def capture_get_or_create(*, slug: str, defaults: dict): monkeypatch.setattr( services_mod.Project.objects, "get_or_create", capture_get_or_create ) + monkeypatch.setattr(services_mod.transaction, "atomic", lambda: nullcontext()) service = services_mod.BoostComponentService( organization="org", diff --git a/tests/postgres/__init__.py b/tests/postgres/__init__.py new file mode 100644 index 0000000..62d857b --- /dev/null +++ b/tests/postgres/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2026 Andrew Zhang +# +# SPDX-License-Identifier: BSL-1.0 diff --git a/tests/postgres/conftest.py b/tests/postgres/conftest.py new file mode 100644 index 0000000..3fe6d6f --- /dev/null +++ b/tests/postgres/conftest.py @@ -0,0 +1,144 @@ +# SPDX-FileCopyrightText: 2026 Andrew Zhang +# +# SPDX-License-Identifier: BSL-1.0 + +"""Shared fixtures for DB-backed PostgreSQL tests (migrations).""" + +from __future__ import annotations + +import os +from contextlib import contextmanager +from copy import deepcopy +from unittest.mock import MagicMock + +import pytest +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core.cache import cache +from rest_framework.settings import api_settings +from rest_framework.test import APIClient +from rest_framework.throttling import SimpleRateThrottle + +from boost_weblate.endpoint.weblate_urls_adapter import register_boost_endpoint_urls + +User = get_user_model() + +pytestmark = pytest.mark.postgres + + +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + """Skip postgres tests when PostgreSQL is not configured.""" + if os.environ.get("CI_DB_HOST"): + return + reason = ( + "CI_DB_HOST is not set; postgres tests require PostgreSQL (see CONTRIBUTING.md)" + ) + skip = pytest.mark.skip(reason=reason) + for item in items: + if "postgres" in item.keywords: + item.add_marker(skip) + + +@pytest.fixture(autouse=True) +def _clear_url_registration_cache() -> None: + register_boost_endpoint_urls.cache_clear() + yield + register_boost_endpoint_urls.cache_clear() + + +class _FakeLock: + """Redis lock stub that always acquires.""" + + def __init__(self) -> None: + self.released = False + + def acquire( + self, blocking: bool = True, blocking_timeout: float | None = None + ) -> bool: + return True + + def release(self) -> None: + self.released = True + + +@pytest.fixture(autouse=True) +def mock_task_lock(monkeypatch: pytest.MonkeyPatch) -> None: + """Default: task lock always acquired unless a test overrides Lock.""" + + def _fake_lock(*_args, **_kwargs) -> _FakeLock: + return _FakeLock() + + monkeypatch.setattr("boost_weblate.utils.task_lock.Lock", _fake_lock) + monkeypatch.setattr( + "boost_weblate.utils.task_lock._get_redis_client", + lambda: MagicMock(), + ) + + +def _throttle_rest_framework(**rate_overrides: str) -> dict: + rf = deepcopy(settings.REST_FRAMEWORK) + rates = dict(rf.get("DEFAULT_THROTTLE_RATES", {})) + rates.update(rate_overrides) + rf["DEFAULT_THROTTLE_RATES"] = rates + return rf + + +@contextmanager +def _isolated_throttle_rates(rest_framework: dict): + """Apply REST_FRAMEWORK throttle rates; restore rates and cache after use.""" + from django.test import override_settings + + with override_settings(REST_FRAMEWORK=rest_framework): + orig = dict(SimpleRateThrottle.THROTTLE_RATES or {}) + cache.clear() + try: + api_settings.reload() + SimpleRateThrottle.THROTTLE_RATES = dict( + api_settings.DEFAULT_THROTTLE_RATES or {} + ) + yield + finally: + cache.clear() + SimpleRateThrottle.THROTTLE_RATES = orig + api_settings.reload() + + +@pytest.fixture +def high_throttle_limits(): + """Relax scoped/user throttles for integration endpoint tests.""" + rest_framework = _throttle_rest_framework( + user="10000/hour", + info="10000/minute", + **{"add-or-update": "10000/hour"}, + ) + with _isolated_throttle_rates(rest_framework): + yield + + +@pytest.fixture +def integration_user(db): + """Persisted user for authenticated integration requests.""" + return User.objects.create_user( + username="integration_test_user", + password="integration-test-password", + email="integration@example.com", + ) + + +@pytest.fixture +def api_client(integration_user): + """DRF APIClient authenticated as ``integration_user``.""" + client = APIClient() + client.force_authenticate(user=integration_user) + return client + + +@pytest.fixture +def mock_add_or_update_delay(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + """Patch AddOrUpdateView Celery enqueue for rejection assertions.""" + delay_mock = MagicMock(return_value=MagicMock(id="integration-task-id")) + monkeypatch.setattr( + "boost_weblate.endpoint.views.boost_add_or_update_task.delay", + delay_mock, + ) + return delay_mock diff --git a/tests/postgres/test_endpoint_trust_boundary.py b/tests/postgres/test_endpoint_trust_boundary.py new file mode 100644 index 0000000..0c00e33 --- /dev/null +++ b/tests/postgres/test_endpoint_trust_boundary.py @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: 2026 Andrew Zhang +# +# SPDX-License-Identifier: BSL-1.0 + +"""Integration tests: HTTP trust boundary through URLconf against a real database.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from django.urls import reverse +from rest_framework import status +from weblate.trans.models import Project + +pytestmark = [pytest.mark.postgres, pytest.mark.django_db] + +_VALID_ADD_OR_UPDATE_BODY = { + "organization": "CppDigest", + "version": "boost-1.90.0", + "add_or_update": {"zh_Hans": ["json"]}, +} + + +@pytest.mark.usefixtures("high_throttle_limits") +class TestAddOrUpdateTrustBoundaryIntegration: + def test_adversarial_payload_rejected_without_db_rows_or_enqueue( + self, + api_client, + mock_add_or_update_delay: MagicMock, + ) -> None: + baseline_count = Project.objects.count() + url = reverse("boost_endpoint:add-or-update") + response = api_client.post( + url, + { + "organization": "'; DROP TABLE--", + "version": "boost-1.0", + "add_or_update": {"zh_Hans": ["json"]}, + }, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "errors" in response.data + assert Project.objects.count() == baseline_count + mock_add_or_update_delay.assert_not_called() + + def test_valid_payload_enqueues_with_real_user_pk_without_project_rows( + self, + api_client, + integration_user, + mock_add_or_update_delay: MagicMock, + ) -> None: + baseline_count = Project.objects.count() + url = reverse("boost_endpoint:add-or-update") + response = api_client.post(url, _VALID_ADD_OR_UPDATE_BODY, format="json") + assert response.status_code == status.HTTP_202_ACCEPTED + assert response.data["task_id"] == "integration-task-id" + mock_add_or_update_delay.assert_called_once_with( + organization="CppDigest", + add_or_update={"zh_Hans": ["json"]}, + version="boost-1.90.0", + extensions=None, + user_id=integration_user.pk, + ) + assert Project.objects.count() == baseline_count diff --git a/tests/postgres/test_services_orm.py b/tests/postgres/test_services_orm.py new file mode 100644 index 0000000..7014a02 --- /dev/null +++ b/tests/postgres/test_services_orm.py @@ -0,0 +1,83 @@ +# SPDX-FileCopyrightText: 2026 Andrew Zhang +# +# SPDX-License-Identifier: BSL-1.0 + +"""Integration tests: BoostComponentService ORM paths against a migrated database.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from weblate.trans.models import Project + +from boost_weblate.endpoint.services import BoostComponentService + +pytestmark = pytest.mark.postgres + + +def _make_svc(**kwargs) -> BoostComponentService: + defaults = dict(organization="boost", lang_code="zh_Hans", version="1.0") + defaults.update(kwargs) + return BoostComponentService(**defaults) + + +@pytest.mark.django_db +class TestGetOrCreateProjectIntegration: + def test_creates_project_with_expected_slug(self, integration_user) -> None: + svc = _make_svc(lang_code="zh_Hans") + project = svc.get_or_create_project("json", user=integration_user) + assert project.slug == "boost-json-documentation-zh_Hans" + assert ( + Project.objects.filter(slug="boost-json-documentation-zh_Hans").count() == 1 + ) + assert project.pk is not None + + def test_idempotent_second_call(self, integration_user) -> None: + svc = _make_svc(lang_code="zh_Hans") + first = svc.get_or_create_project("json", user=integration_user) + second = svc.get_or_create_project("json", user=integration_user) + assert first.pk == second.pk + assert ( + Project.objects.filter(slug="boost-json-documentation-zh_Hans").count() == 1 + ) + + def test_different_lang_code_distinct_slug(self, integration_user) -> None: + svc_zh = _make_svc(lang_code="zh_Hans") + svc_ja = _make_svc(lang_code="ja") + project_zh = svc_zh.get_or_create_project("json", user=integration_user) + project_ja = svc_ja.get_or_create_project("json", user=integration_user) + assert project_zh.slug == "boost-json-documentation-zh_Hans" + assert project_ja.slug == "boost-json-documentation-ja" + assert project_zh.pk != project_ja.pk + assert ( + Project.objects.filter(slug__startswith="boost-json-documentation-").count() + == 2 + ) + + def test_post_create_called_for_new_project(self, integration_user) -> None: + svc = _make_svc(lang_code="zh_Hans") + original_post_create = Project.post_create + with patch.object(Project, "post_create", autospec=True) as post_create_spy: + post_create_spy.side_effect = original_post_create + project = svc.get_or_create_project("my_lib", user=integration_user) + post_create_spy.assert_called_once_with(project, integration_user, billing=None) + assert project.slug == "boost-my-lib-documentation-zh_Hans" + assert project.acting_user == integration_user + assert Project.objects.filter(slug=project.slug).exists() + + @pytest.mark.django_db(transaction=False) + def test_transaction_rollback_on_post_create_failure( + self, integration_user + ) -> None: + svc = _make_svc(lang_code="zh_Hans") + slug = "boost-json-documentation-zh_Hans" + assert not Project.objects.filter(slug=slug).exists() + + with ( + patch.object(Project, "post_create", side_effect=RuntimeError("boom")), + pytest.raises(RuntimeError, match="boom"), + ): + svc.get_or_create_project("json", user=integration_user) + + assert not Project.objects.filter(slug=slug).exists() diff --git a/uv.lock b/uv.lock index 7e2ba6b..0750562 100644 --- a/uv.lock +++ b/uv.lock @@ -662,7 +662,8 @@ dev = [ {name = "coverage"}, {name = "hypothesis"}, {name = "pytest-benchmark"}, - {name = "pytest-cov"} + {name = "pytest-cov"}, + {name = "pytest-django"} ] lint = [ {name = "hypothesis"}, @@ -693,6 +694,7 @@ requires-dist = [ {name = "prek", marker = "extra == 'dev'", specifier = "==0.4.4"}, {name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.3"}, {name = "pytest-cov", marker = "extra == 'dev'", specifier = "==7.1.0"}, + {name = "pytest-django", marker = "extra == 'dev'", specifier = "==4.12.0"}, {name = "weblate", extras = ["postgres"], specifier = "==2026.5"} ] @@ -701,7 +703,8 @@ dev = [ {name = "coverage", extras = ["toml"], specifier = "==7.14.1"}, {name = "hypothesis", specifier = "==6.155.3"}, {name = "pytest-benchmark", specifier = "==5.2.3"}, - {name = "pytest-cov", specifier = "==7.1.0"} + {name = "pytest-cov", specifier = "==7.1.0"}, + {name = "pytest-django", specifier = "==4.12.0"} ] lint = [ {name = "hypothesis", specifier = "==6.155.3"}, @@ -730,7 +733,8 @@ dev = [ {name = "hypothesis"}, {name = "prek"}, {name = "pytest"}, - {name = "pytest-cov"} + {name = "pytest-cov"}, + {name = "pytest-django"} ] [[package]] @@ -2029,6 +2033,18 @@ wheels = [ {url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z"} ] +[[package]] +dependencies = [ + {name = "pytest"} +] +name = "pytest-django" +sdist = {url = "https://files.pythonhosted.org/packages/13/2b/db9a193df89e5660137f5428063bcc2ced7ad790003b26974adf5c5ceb3b/pytest_django-4.12.0.tar.gz", hash = "sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758", size = 91156, upload-time = "2026-02-14T18:40:49.235Z"} +source = {registry = "https://pypi.org/simple"} +version = "4.12.0" +wheels = [ + {url = "https://files.pythonhosted.org/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85", size = 26123, upload-time = "2026-02-14T18:40:47.381Z"} +] + [[package]] dependencies = [ {name = "packaging"},