From be704ef687a827c4f30167227cf8bd0342c0c327 Mon Sep 17 00:00:00 2001 From: whisper67265 Date: Mon, 1 Jun 2026 15:39:43 -0600 Subject: [PATCH 1/6] Rate limiting on boost endpoint and test --- docker/docker-compose.ci.yml | 2 + scripts/integration-auth.sh | 4 +- src/boost_weblate/endpoint/views.py | 18 ++++ src/boost_weblate/settings_override.py | 40 +++++++++ tests/django_qbk_format_settings.py | 4 + tests/endpoint/test_views.py | 117 ++++++++++++++++++++++++- tests/integration/lib/http.py | 49 +++++++++++ tests/integration/test_rate_limit.py | 83 ++++++++++++++++++ tests/test_settings_override.py | 13 +++ 9 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 tests/integration/test_rate_limit.py diff --git a/docker/docker-compose.ci.yml b/docker/docker-compose.ci.yml index b25a31f..e030d36 100644 --- a/docker/docker-compose.ci.yml +++ b/docker/docker-compose.ci.yml @@ -52,6 +52,8 @@ services: REDIS_HOST: redis REDIS_PORT: '6379' CELERY_SINGLE_PROCESS: '1' + BOOST_ENDPOINT_THROTTLE_INFO: 3/minute + BOOST_ENDPOINT_THROTTLE_ADD_OR_UPDATE: 3/hour healthcheck: test: [CMD, curl, -f, http://localhost:8080/healthz/] interval: 10s diff --git a/scripts/integration-auth.sh b/scripts/integration-auth.sh index 9240cdc..7f3d16e 100755 --- a/scripts/integration-auth.sh +++ b/scripts/integration-auth.sh @@ -38,8 +38,10 @@ export WEBLATE_API_TOKEN export WEBLATE_LIVE_BASE_URL="${WEBLATE_LIVE_BASE_URL:-http://localhost:${WEBLATE_PORT:-8080}}" export WEBLATE_COMPOSE_FILE="${COMPOSE_FILE}" export WEBLATE_COMPOSE_PROJECT="${COMPOSE_PROJECT_NAME}" +export BOOST_ENDPOINT_THROTTLE_INFO="${BOOST_ENDPOINT_THROTTLE_INFO:-3/minute}" +export BOOST_ENDPOINT_THROTTLE_ADD_OR_UPDATE="${BOOST_ENDPOINT_THROTTLE_ADD_OR_UPDATE:-3/hour}" echo "=== Running auth tests ===" uv pip install --quiet --system --group integration python -m pytest --confcutdir=tests/integration --override-ini addopts= \ - tests/integration/test_auth.py -v + tests/integration/test_auth.py tests/integration/test_rate_limit.py -v diff --git a/src/boost_weblate/endpoint/views.py b/src/boost_weblate/endpoint/views.py index e0400a0..38ac77b 100644 --- a/src/boost_weblate/endpoint/views.py +++ b/src/boost_weblate/endpoint/views.py @@ -11,7 +11,9 @@ from rest_framework import status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView +from weblate.api.throttling import UserRateThrottle, patch_throttle_request from boost_weblate.endpoint.serializers import AddOrUpdateRequestSerializer from boost_weblate.endpoint.tasks import boost_add_or_update_task @@ -35,10 +37,24 @@ def plugin_ping(_request): return HttpResponse("ok", content_type="text/plain") +class BoostEndpointInfoThrottle(ScopedRateThrottle): + @patch_throttle_request + def allow_request(self, request, view): + return super().allow_request(request, view) + + +class AddOrUpdateThrottle(ScopedRateThrottle): + @patch_throttle_request + def allow_request(self, request, view): + return super().allow_request(request, view) + + class BoostEndpointInfo(APIView): """Boost documentation translation API info.""" permission_classes = (IsAuthenticated,) + throttle_scope = "info" + throttle_classes = (UserRateThrottle, BoostEndpointInfoThrottle) def get(self, request, format=None): # noqa: A002 """Return module name, version, and supported capabilities.""" @@ -55,6 +71,8 @@ class AddOrUpdateView(APIView): """Add or update Boost documentation components.""" permission_classes = (IsAuthenticated,) + throttle_scope = "add-or-update" + throttle_classes = (UserRateThrottle, AddOrUpdateThrottle) def post(self, request, format=None): # noqa: A002 """ diff --git a/src/boost_weblate/settings_override.py b/src/boost_weblate/settings_override.py index 6922c77..5397172 100644 --- a/src/boost_weblate/settings_override.py +++ b/src/boost_weblate/settings_override.py @@ -28,8 +28,10 @@ from __future__ import annotations +import os import re from pathlib import Path +from typing import Any # Package ``__init__`` is empty; does not import ``formats.models``. import weblate.formats @@ -69,6 +71,44 @@ def weblate_formats_with_quickbook() -> tuple[str, ...]: WEBLATE_FORMATS = weblate_formats_with_quickbook() +_DEFAULT_BOOST_ENDPOINT_THROTTLE_RATES = { + "info": "60/minute", + "add-or-update": "10/hour", +} + + +def boost_endpoint_throttle_rates() -> dict[str, str]: + """Scoped throttle rates for Boost endpoint views (env overrides optional).""" + return { + "info": os.environ.get( + "BOOST_ENDPOINT_THROTTLE_INFO", + _DEFAULT_BOOST_ENDPOINT_THROTTLE_RATES["info"], + ), + "add-or-update": os.environ.get( + "BOOST_ENDPOINT_THROTTLE_ADD_OR_UPDATE", + _DEFAULT_BOOST_ENDPOINT_THROTTLE_RATES["add-or-update"], + ), + } + + +BOOST_ENDPOINT_THROTTLE_RATES = boost_endpoint_throttle_rates() + + +def merge_boost_endpoint_throttle_rates( + rest_framework: dict[str, Any], +) -> dict[str, Any]: + """Merge Boost endpoint scoped rates into ``REST_FRAMEWORK``.""" + merged = dict(rest_framework) + existing = dict(merged.get("DEFAULT_THROTTLE_RATES", {})) + existing.update(BOOST_ENDPOINT_THROTTLE_RATES) + merged["DEFAULT_THROTTLE_RATES"] = existing + return merged + + +_REST_FRAMEWORK = globals().get("REST_FRAMEWORK") +if _REST_FRAMEWORK is not None: + globals()["REST_FRAMEWORK"] = merge_boost_endpoint_throttle_rates(_REST_FRAMEWORK) + _INSTALLED_APPS = globals().get("INSTALLED_APPS") if _INSTALLED_APPS is not None: # Tuple += creates a new object; assign back so exec namespace / settings see it. diff --git a/tests/django_qbk_format_settings.py b/tests/django_qbk_format_settings.py index f67ecd5..8e0bcb0 100644 --- a/tests/django_qbk_format_settings.py +++ b/tests/django_qbk_format_settings.py @@ -23,6 +23,8 @@ import weblate.settings_example as _wl_example +from boost_weblate.settings_override import merge_boost_endpoint_throttle_rates + for _key, _value in _wl_example.__dict__.items(): if _key.isupper(): globals()[_key] = _value @@ -59,3 +61,5 @@ PASSWORD_HASHERS = [ "django.contrib.auth.hashers.MD5PasswordHasher", ] + +REST_FRAMEWORK = merge_boost_endpoint_throttle_rates(_wl_example.REST_FRAMEWORK) diff --git a/tests/endpoint/test_views.py b/tests/endpoint/test_views.py index 7bccf21..718454d 100644 --- a/tests/endpoint/test_views.py +++ b/tests/endpoint/test_views.py @@ -5,14 +5,19 @@ from __future__ import annotations import importlib.metadata +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.contrib.auth.models import AnonymousUser -from django.test import RequestFactory +from django.core.cache import cache +from django.test import RequestFactory, override_settings from rest_framework import status +from rest_framework.settings import api_settings from rest_framework.test import APIRequestFactory, force_authenticate +from rest_framework.throttling import SimpleRateThrottle from boost_weblate.endpoint.views import ( AddOrUpdateView, @@ -22,6 +27,25 @@ User = get_user_model() +_ADD_OR_UPDATE_BODY = { + "organization": "o", + "version": "v", + "add_or_update": {"zh_Hans": ["json"]}, +} + + +def _reload_throttle_rates() -> None: + api_settings.reload() + SimpleRateThrottle.THROTTLE_RATES = api_settings.DEFAULT_THROTTLE_RATES + + +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 + @pytest.fixture def weblate_anonymous_user_no_db(monkeypatch: pytest.MonkeyPatch) -> None: @@ -205,3 +229,94 @@ def process_all(self, _submodules, *, user, request=None): # noqa: ANN001 extensions=None, user_id=1, ) + + +@override_settings( + REST_FRAMEWORK=_throttle_rest_framework( + user="10000/hour", + info="2/minute", + **{"add-or-update": "2/minute"}, + ) +) +def test_boost_endpoint_info_returns_429_when_scoped_throttled() -> None: + cache.clear() + _reload_throttle_rates() + + factory = APIRequestFactory() + user = User(username="t_throttle_info", pk=101) + view = BoostEndpointInfo.as_view() + + for _ in range(2): + request = factory.get("/info/") + force_authenticate(request, user=user) + response = view(request) + assert response.status_code == status.HTTP_200_OK + + request = factory.get("/info/") + force_authenticate(request, user=user) + response = view(request) + assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS + assert "Retry-After" in response + assert int(response["Retry-After"]) > 0 + + +@override_settings( + REST_FRAMEWORK=_throttle_rest_framework( + user="10000/hour", + info="2/minute", + **{"add-or-update": "2/minute"}, + ) +) +def test_add_or_update_returns_429_when_scoped_throttled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + cache.clear() + _reload_throttle_rates() + + delay_mock = MagicMock(return_value=MagicMock(id="task-uuid")) + monkeypatch.setattr( + "boost_weblate.endpoint.views.boost_add_or_update_task.delay", + delay_mock, + ) + + factory = APIRequestFactory() + user = User(username="t_throttle_aou", pk=102) + view = AddOrUpdateView.as_view() + + for _ in range(2): + request = factory.post("/add-or-update/", _ADD_OR_UPDATE_BODY, format="json") + force_authenticate(request, user=user) + response = view(request) + assert response.status_code == status.HTTP_202_ACCEPTED + + request = factory.post("/add-or-update/", _ADD_OR_UPDATE_BODY, format="json") + force_authenticate(request, user=user) + response = view(request) + assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS + assert "Retry-After" in response + assert int(response["Retry-After"]) > 0 + assert delay_mock.call_count == 2 + + +@override_settings( + REST_FRAMEWORK=_throttle_rest_framework(user="2/minute", info="10000/minute") +) +def test_boost_endpoint_info_user_throttle_can_429() -> None: + cache.clear() + _reload_throttle_rates() + + factory = APIRequestFactory() + user = User(username="t_user_throttle", pk=103) + view = BoostEndpointInfo.as_view() + + for _ in range(2): + request = factory.get("/info/") + force_authenticate(request, user=user) + response = view(request) + assert response.status_code == status.HTTP_200_OK + + request = factory.get("/info/") + force_authenticate(request, user=user) + response = view(request) + assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS + assert "Retry-After" in response diff --git a/tests/integration/lib/http.py b/tests/integration/lib/http.py index 648d5c8..2a6b3b3 100644 --- a/tests/integration/lib/http.py +++ b/tests/integration/lib/http.py @@ -22,6 +22,49 @@ def auth_header(token: str) -> str: return f"Token {token}" +def _response_headers(header_obj) -> dict[str, str]: + return {k: v for k, v in header_obj.items()} + + +def http_json_with_headers( + method: str, + path: str, + *, + token: str | None = None, + body: dict[str, Any] | None = None, + timeout: float = 30.0, +) -> tuple[int, Any, dict[str, str]]: + """Perform an HTTP request and return ``(status_code, body, response_headers)``.""" + url = f"{base_url()}{path}" + headers: dict[str, str] = {"Accept": "application/json"} + if token is not None: + headers["Authorization"] = auth_header(token) + + data: bytes | None = None + if body is not None: + data = json.dumps(body).encode() + headers["Content-Type"] = "application/json" + + req = urllib.request.Request(url, data=data, headers=headers, method=method) + resp_headers: dict[str, str] = {} + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw = resp.read() + code: int = resp.getcode() + resp_headers = _response_headers(resp.headers) + except urllib.error.HTTPError as e: + raw = e.read() + code = e.code + resp_headers = _response_headers(e.headers) + + if not raw: + return code, None, resp_headers + try: + return code, json.loads(raw.decode()), resp_headers + except (json.JSONDecodeError, UnicodeDecodeError): + return code, raw.decode(errors="replace"), resp_headers + + def http_json( method: str, path: str, @@ -62,3 +105,9 @@ def http_get( path: str, *, token: str | None = None, timeout: float = 30.0 ) -> tuple[int, Any]: return http_json("GET", path, token=token, timeout=timeout) + + +def http_get_with_headers( + path: str, *, token: str | None = None, timeout: float = 30.0 +) -> tuple[int, Any, dict[str, str]]: + return http_json_with_headers("GET", path, token=token, timeout=timeout) diff --git a/tests/integration/test_rate_limit.py b/tests/integration/test_rate_limit.py new file mode 100644 index 0000000..38e762d --- /dev/null +++ b/tests/integration/test_rate_limit.py @@ -0,0 +1,83 @@ +# SPDX-FileCopyrightText: 2026 Andrew Zhang +# +# SPDX-License-Identifier: BSL-1.0 + +"""Integration tests for Boost endpoint rate limiting.""" + +from __future__ import annotations + +import os +import re + +import pytest + +from tests.integration.lib.http import http_get_with_headers, http_json_with_headers + +pytestmark = pytest.mark.integration + +_VALID_ADD_OR_UPDATE_BODY = { + "organization": "test-org", + "version": "test-1.0.0", + "add_or_update": {"zh_Hans": ["test-submodule"]}, +} + +_RATE_PATTERN = re.compile(r"^(\d+)/(minute|hour|min|h|day|d)$") + + +def _parse_rate_limit(rate: str) -> int: + match = _RATE_PATTERN.match(rate.strip()) + if not match: + msg = f"unsupported throttle rate format: {rate!r}" + raise ValueError(msg) + return int(match.group(1)) + + +class TestBoostEndpointRateLimit: + """Live-stack rate limit enforcement for Boost endpoint routes.""" + + def test_info_returns_429_when_rate_limited(self, api_token: str) -> None: + rate = os.environ.get("BOOST_ENDPOINT_THROTTLE_INFO", "3/minute") + limit = _parse_rate_limit(rate) + + last_headers: dict[str, str] = {} + for _ in range(limit): + code, _body, headers = http_get_with_headers( + "/boost-endpoint/info/", token=api_token + ) + assert code == 200, f"expected 200 before limit: {code}" + last_headers = headers + + code, _body, headers = http_get_with_headers( + "/boost-endpoint/info/", token=api_token + ) + assert code == 429, f"expected 429 after {limit} requests: {code}" + retry_after = headers.get("Retry-After") + assert retry_after is not None + assert int(retry_after) > 0 + + if "X-RateLimit-Limit" in last_headers: + assert int(last_headers["X-RateLimit-Limit"]) == limit + + def test_add_or_update_returns_429_when_rate_limited(self, api_token: str) -> None: + rate = os.environ.get("BOOST_ENDPOINT_THROTTLE_ADD_OR_UPDATE", "3/hour") + limit = _parse_rate_limit(rate) + + for _ in range(limit): + code, _body, _headers = http_json_with_headers( + "POST", + "/boost-endpoint/add-or-update/", + token=api_token, + body=_VALID_ADD_OR_UPDATE_BODY, + ) + assert code == 202, f"expected 202 before limit: {code}" + + code, _body, headers = http_json_with_headers( + "POST", + "/boost-endpoint/add-or-update/", + token=api_token, + body=_VALID_ADD_OR_UPDATE_BODY, + ) + assert code == 429, f"expected 429 after {limit} requests: {code}" + retry_after = headers.get("Retry-After") + assert retry_after is not None + assert int(retry_after) > 0 diff --git a/tests/test_settings_override.py b/tests/test_settings_override.py index 6b9e940..7ecff53 100644 --- a/tests/test_settings_override.py +++ b/tests/test_settings_override.py @@ -91,3 +91,16 @@ def test_weblate_formats_includes_upstream_and_quickbook() -> None: assert "weblate.formats.ttkit.PoFormat" in paths assert "weblate.formats.ttkit.TBXFormat" in paths assert paths.count(_QBK) == 1 + + +def test_merge_boost_endpoint_throttle_rates_preserves_upstream() -> None: + from boost_weblate.settings_override import merge_boost_endpoint_throttle_rates + + merged = merge_boost_endpoint_throttle_rates( + {"DEFAULT_THROTTLE_RATES": {"user": "1/hour", "anon": "100/day"}} + ) + rates = merged["DEFAULT_THROTTLE_RATES"] + assert rates["user"] == "1/hour" + assert rates["anon"] == "100/day" + assert rates["info"] == "60/minute" + assert rates["add-or-update"] == "10/hour" From 12496be7d73fd153f2e585a8b26e81d3077d66c4 Mon Sep 17 00:00:00 2001 From: whisper67265 Date: Mon, 1 Jun 2026 15:46:45 -0600 Subject: [PATCH 2/6] rename some prefix and folder --- .github/README.md | 8 ++++---- ...i-combination-auth.yml => ci-plugin-auth.yml} | 8 ++++---- ...n-functional.yml => ci-plugin-functional.yml} | 12 ++++++------ ...combination-smoke.yml => ci-plugin-smoke.yml} | 8 ++++---- .github/workflows/ci.yml | 12 ++++++------ README.md | 12 ++++++------ docker/README.md | 4 ++-- docker/docker-compose.ci.yml | 2 +- pyproject.toml | 12 ++++++------ scripts/integration-auth.sh | 6 +++--- scripts/integration-functional.sh | 6 +++--- scripts/integration-smoke.sh | 6 +++--- tests/{integration => plugin}/__init__.py | 0 tests/{integration => plugin}/conftest.py | 10 +++++----- tests/{integration => plugin}/lib/__init__.py | 2 +- tests/{integration => plugin}/lib/docker_exec.py | 0 tests/{integration => plugin}/lib/gh_repo.py | 0 tests/{integration => plugin}/lib/http.py | 0 tests/{integration => plugin}/lib/weblate_api.py | 4 ++-- tests/{integration => plugin}/test_auth.py | 4 ++-- tests/{integration => plugin}/test_functional.py | 10 +++++----- tests/{integration => plugin}/test_rate_limit.py | 4 ++-- tests/{integration => plugin}/test_smoke.py | 4 ++-- uv.lock | 16 ++++++++-------- 24 files changed, 75 insertions(+), 75 deletions(-) rename .github/workflows/{ci-combination-auth.yml => ci-plugin-auth.yml} (90%) rename .github/workflows/{ci-combination-functional.yml => ci-plugin-functional.yml} (83%) rename .github/workflows/{ci-combination-smoke.yml => ci-plugin-smoke.yml} (89%) rename tests/{integration => plugin}/__init__.py (100%) rename tests/{integration => plugin}/conftest.py (85%) rename tests/{integration => plugin}/lib/__init__.py (71%) rename tests/{integration => plugin}/lib/docker_exec.py (100%) rename tests/{integration => plugin}/lib/gh_repo.py (100%) rename tests/{integration => plugin}/lib/http.py (100%) rename tests/{integration => plugin}/lib/weblate_api.py (99%) rename tests/{integration => plugin}/test_auth.py (96%) rename tests/{integration => plugin}/test_functional.py (97%) rename tests/{integration => plugin}/test_rate_limit.py (95%) rename tests/{integration => plugin}/test_smoke.py (97%) diff --git a/.github/README.md b/.github/README.md index 826f7f8..952fe01 100644 --- a/.github/README.md +++ b/.github/README.md @@ -18,11 +18,11 @@ GitHub Actions and CI/CD helpers for this repository. | [`workflows/ci-test.yml`](workflows/ci-test.yml) | Unit tests and coverage | | [`workflows/ci-package.yml`](workflows/ci-package.yml) | Build and package checks | | [`workflows/ci-dependencies.yml`](workflows/ci-dependencies.yml) | Dependency and license audit | -| [`workflows/ci-combination-smoke.yml`](workflows/ci-combination-smoke.yml) | Integration smoke (Docker stack) | -| [`workflows/ci-combination-functional.yml`](workflows/ci-combination-functional.yml) | Integration functional tests | -| [`workflows/ci-combination-auth.yml`](workflows/ci-combination-auth.yml) | Integration auth tests | +| [`workflows/ci-plugin-smoke.yml`](workflows/ci-plugin-smoke.yml) | Integration smoke (Docker stack) | +| [`workflows/ci-plugin-functional.yml`](workflows/ci-plugin-functional.yml) | Integration functional tests | +| [`workflows/ci-plugin-auth.yml`](workflows/ci-plugin-auth.yml) | Integration auth tests | -Callable workflows (`ci-*`, `ci-combination-*`) are triggered only via `workflow_call` from `ci.yml`, not directly on push. +Callable workflows (`ci-*`, `ci-plugin-*`) are triggered only via `workflow_call` from `ci.yml`, not directly on push. ## Other paths diff --git a/.github/workflows/ci-combination-auth.yml b/.github/workflows/ci-plugin-auth.yml similarity index 90% rename from .github/workflows/ci-combination-auth.yml rename to .github/workflows/ci-plugin-auth.yml index 8a2097d..de582c2 100644 --- a/.github/workflows/ci-combination-auth.yml +++ b/.github/workflows/ci-plugin-auth.yml @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: BSL-1.0 -name: Combination auth +name: Plugin auth on: workflow_call: @@ -12,8 +12,8 @@ permissions: contents: read jobs: - combination-auth: - name: Combination auth + plugin-auth: + name: Plugin auth runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -40,5 +40,5 @@ jobs: # actions/upload-artifact v4.6.2 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: - name: ci-combination-auth-logs + name: ci-plugin-auth-logs path: /tmp/compose-logs.txt diff --git a/.github/workflows/ci-combination-functional.yml b/.github/workflows/ci-plugin-functional.yml similarity index 83% rename from .github/workflows/ci-combination-functional.yml rename to .github/workflows/ci-plugin-functional.yml index 2ddbd2f..840ead6 100644 --- a/.github/workflows/ci-combination-functional.yml +++ b/.github/workflows/ci-plugin-functional.yml @@ -2,16 +2,16 @@ # # SPDX-License-Identifier: BSL-1.0 -name: Combination functional +name: Plugin functional on: workflow_call: secrets: # GitHub classic PAT with `repo` scope (repository secret, not a variable). - # Used by tests/integration/lib/gh_repo.py to create a temporary repo, + # Used by tests/plugin/lib/gh_repo.py to create a temporary repo, # push QuickBook fixtures, register Weblate's SSH deploy key, and delete # the repo after the run. Enables add-or-update / BoostComponentService E2E - # in tests/integration/test_functional.py. If unset, those tests are skipped + # in tests/plugin/test_functional.py. If unset, those tests are skipped # (smoke round-trip and other non-GitHub tests still run). Inherited from # the caller via secrets: inherit in ci.yml. GH_TEST_REPO_TOKEN: @@ -22,8 +22,8 @@ permissions: contents: read jobs: - combination-functional: - name: Combination functional + plugin-functional: + name: Plugin functional runs-on: ubuntu-latest timeout-minutes: 25 steps: @@ -52,5 +52,5 @@ jobs: # actions/upload-artifact v4.6.2 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: - name: ci-combination-functional-logs + name: ci-plugin-functional-logs path: /tmp/compose-logs.txt diff --git a/.github/workflows/ci-combination-smoke.yml b/.github/workflows/ci-plugin-smoke.yml similarity index 89% rename from .github/workflows/ci-combination-smoke.yml rename to .github/workflows/ci-plugin-smoke.yml index e38ff8c..04233df 100644 --- a/.github/workflows/ci-combination-smoke.yml +++ b/.github/workflows/ci-plugin-smoke.yml @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: BSL-1.0 -name: Combination smoke +name: Plugin smoke on: workflow_call: @@ -12,8 +12,8 @@ permissions: contents: read jobs: - combination-smoke: - name: Combination smoke + plugin-smoke: + name: Plugin smoke runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -40,5 +40,5 @@ jobs: # actions/upload-artifact v4.6.2 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: - name: ci-combination-smoke-logs + name: ci-plugin-smoke-logs path: /tmp/compose-logs.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5333fb..68f196e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,10 +23,10 @@ jobs: uses: ./.github/workflows/ci-package.yml dependencies: uses: ./.github/workflows/ci-dependencies.yml - combination-smoke: - uses: ./.github/workflows/ci-combination-smoke.yml - combination-functional: - uses: ./.github/workflows/ci-combination-functional.yml + plugin-smoke: + uses: ./.github/workflows/ci-plugin-smoke.yml + plugin-functional: + uses: ./.github/workflows/ci-plugin-functional.yml secrets: inherit - combination-auth: - uses: ./.github/workflows/ci-combination-auth.yml + plugin-auth: + uses: ./.github/workflows/ci-plugin-auth.yml diff --git a/README.md b/README.md index 7861a51..3c488fa 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,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 integration tests live in `tests/integration/`. +- **`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/`. ## WEBLATE_FORMATS configuration @@ -265,11 +265,11 @@ Triggered on push and PR to `main` and `develop`. Calls seven reusable sub-workf | `test` | [`.github/workflows/ci-test.yml`](.github/workflows/ci-test.yml) | pytest + 90% coverage gate (`--cov-fail-under=90`) | | `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) | -| `combination-smoke` | [`.github/workflows/ci-combination-smoke.yml`](.github/workflows/ci-combination-smoke.yml) | Docker stack → P0 smoke tests (`scripts/integration-smoke.sh`) | -| `combination-auth` | [`.github/workflows/ci-combination-auth.yml`](.github/workflows/ci-combination-auth.yml) | Docker stack → auth tests (`scripts/integration-auth.sh`) | -| `combination-functional` | [`.github/workflows/ci-combination-functional.yml`](.github/workflows/ci-combination-functional.yml) | Docker stack → E2E functional tests (`scripts/integration-functional.sh`); optional `GH_TEST_REPO_TOKEN` secret for GitHub-backed tests | +| `plugin-smoke` | [`.github/workflows/ci-plugin-smoke.yml`](.github/workflows/ci-plugin-smoke.yml) | Docker stack → P0 smoke tests (`scripts/integration-smoke.sh`) | +| `plugin-auth` | [`.github/workflows/ci-plugin-auth.yml`](.github/workflows/ci-plugin-auth.yml) | Docker stack → auth tests (`scripts/integration-auth.sh`) | +| `plugin-functional` | [`.github/workflows/ci-plugin-functional.yml`](.github/workflows/ci-plugin-functional.yml) | Docker stack → E2E functional tests (`scripts/integration-functional.sh`); optional `GH_TEST_REPO_TOKEN` secret for GitHub-backed tests | -All `ci-combination-*` jobs build the CI Docker stack (`docker/docker-compose.ci.yml`), wait for the healthcheck, create an API token, run the corresponding pytest suite under `tests/integration/`, and tear down. +All `ci-plugin-*` jobs build the CI Docker stack (`docker/docker-compose.ci.yml`), wait for the healthcheck, create an API token, run the corresponding pytest suite under `tests/plugin/`, and tear down. ### CD (`cd.yml`) @@ -277,7 +277,7 @@ Triggered after CI succeeds on a `develop` push. SSHes into the staging server a Full deployment procedure: [docs/deployment-runbook.md](docs/deployment-runbook.md). -### Running integration tests locally +### Running plugin tests locally ```bash # Smoke (P0 — container boot, format registration, URL registration): diff --git a/docker/README.md b/docker/README.md index b7b9662..e619669 100644 --- a/docker/README.md +++ b/docker/README.md @@ -9,13 +9,13 @@ SPDX-License-Identifier: BSL-1.0 Shared Docker assets for CI and CD. - **Dockerfile.weblate-plugin** — Overlay on `weblate/weblate:latest`; installs the plugin via `uv pip install` and copies `settings-override.py`. -- **docker-compose.ci.yml** — PostgreSQL + Redis + Weblate stack for integration tests and CI. +- **docker-compose.ci.yml** — PostgreSQL + Redis + Weblate stack for plugin tests and CI. - **docker-compose.cd.yml** — Weblate-only stack for staging/production (host Postgres, shared Redis). ## Usage ```bash -# CI / integration tests (from repo root): +# CI / plugin tests (from repo root): docker compose -f docker/docker-compose.ci.yml build docker compose -f docker/docker-compose.ci.yml up -d diff --git a/docker/docker-compose.ci.yml b/docker/docker-compose.ci.yml index e030d36..39f283e 100644 --- a/docker/docker-compose.ci.yml +++ b/docker/docker-compose.ci.yml @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: BSL-1.0 -# CI / integration tests: bundled PostgreSQL + Redis + Weblate (ephemeral Postgres). +# CI / plugin tests: bundled PostgreSQL + Redis + Weblate (ephemeral Postgres). # CI: docker compose -f docker/docker-compose.ci.yml build && docker compose -f docker/docker-compose.ci.yml up -d # CD: docker compose -f docker/docker-compose.cd.yml --env-file .env up -d diff --git a/pyproject.toml b/pyproject.toml index 46d29ed..198b87a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,13 +11,13 @@ dev = [ "coverage[toml]>=7.6.0", "pytest-cov>=7.1.0" ] -integration = [ - "pytest>=8.3", - "pytest-timeout>=2.3.1" -] lint = [ {include-group = "pre-commit"} ] +plugin = [ + "pytest>=8.3", + "pytest-timeout>=2.3.1" +] pre-commit = [ "prek==0.3.13", "pytest>=8.3" @@ -130,9 +130,9 @@ level = "cautious" unauthorized_licenses = [] [tool.pytest.ini_options] -addopts = ["-m", "not integration"] +addopts = ["-m", "not plugin"] markers = [ - "integration: requires live Weblate stack (Docker Compose) and optional WEBLATE_API_TOKEN" + "plugin: requires live Weblate stack (Docker Compose) and optional WEBLATE_API_TOKEN" ] python_classes = ["Test*"] python_files = ["test_*.py", "*_test.py"] diff --git a/scripts/integration-auth.sh b/scripts/integration-auth.sh index 7f3d16e..8361c68 100755 --- a/scripts/integration-auth.sh +++ b/scripts/integration-auth.sh @@ -42,6 +42,6 @@ export BOOST_ENDPOINT_THROTTLE_INFO="${BOOST_ENDPOINT_THROTTLE_INFO:-3/minute}" export BOOST_ENDPOINT_THROTTLE_ADD_OR_UPDATE="${BOOST_ENDPOINT_THROTTLE_ADD_OR_UPDATE:-3/hour}" echo "=== Running auth tests ===" -uv pip install --quiet --system --group integration -python -m pytest --confcutdir=tests/integration --override-ini addopts= \ - tests/integration/test_auth.py tests/integration/test_rate_limit.py -v +uv pip install --quiet --system --group plugin +python -m pytest --confcutdir=tests/plugin --override-ini addopts= \ + tests/plugin/test_auth.py tests/plugin/test_rate_limit.py -v diff --git a/scripts/integration-functional.sh b/scripts/integration-functional.sh index e1af16d..f43cf33 100755 --- a/scripts/integration-functional.sh +++ b/scripts/integration-functional.sh @@ -58,6 +58,6 @@ else fi echo "=== Running functional tests ===" -uv pip install --quiet --system --group integration -python -m pytest --confcutdir=tests/integration --override-ini addopts= \ - tests/integration/test_functional.py -v --timeout=300 +uv pip install --quiet --system --group plugin +python -m pytest --confcutdir=tests/plugin --override-ini addopts= \ + tests/plugin/test_functional.py -v --timeout=300 diff --git a/scripts/integration-smoke.sh b/scripts/integration-smoke.sh index 52f70c4..3910857 100755 --- a/scripts/integration-smoke.sh +++ b/scripts/integration-smoke.sh @@ -42,6 +42,6 @@ export WEBLATE_COMPOSE_PROJECT="${COMPOSE_PROJECT_NAME}" echo "=== Running smoke tests ===" # Same pytest floor as dev/pre-commit (pyproject.toml); not pinned to uv.lock. # --system: setup-python on CI has no project venv (matches ci-dependencies.yml). -uv pip install --quiet --system --group integration -# Do not load tests/conftest.py (Django host setup); integration tests only need pytest + stdlib. -python -m pytest --confcutdir=tests/integration --override-ini addopts= tests/integration/test_smoke.py -v +uv pip install --quiet --system --group plugin +# Do not load tests/conftest.py (Django host setup); plugin tests only need pytest + stdlib. +python -m pytest --confcutdir=tests/plugin --override-ini addopts= tests/plugin/test_smoke.py -v diff --git a/tests/integration/__init__.py b/tests/plugin/__init__.py similarity index 100% rename from tests/integration/__init__.py rename to tests/plugin/__init__.py diff --git a/tests/integration/conftest.py b/tests/plugin/conftest.py similarity index 85% rename from tests/integration/conftest.py rename to tests/plugin/conftest.py index 4401ee3..5e74a91 100644 --- a/tests/integration/conftest.py +++ b/tests/plugin/conftest.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: BSL-1.0 -"""Shared fixtures for integration tests (smoke + functional).""" +"""Shared fixtures for plugin tests (smoke + functional).""" from __future__ import annotations @@ -12,10 +12,10 @@ import pytest -from tests.integration.lib.docker_exec import docker_exec_python, docker_exec_read_file -from tests.integration.lib.gh_repo import EphemeralGitHubRepo, default_repo_name -from tests.integration.lib.http import base_url -from tests.integration.lib.weblate_api import WeblateAPI +from tests.plugin.lib.docker_exec import docker_exec_python, docker_exec_read_file +from tests.plugin.lib.gh_repo import EphemeralGitHubRepo, default_repo_name +from tests.plugin.lib.http import base_url +from tests.plugin.lib.weblate_api import WeblateAPI FIXTURES_DIR = Path(__file__).resolve().parent.parent / "fixtures" TEST_LANG_CODE = "zh_Hans" diff --git a/tests/integration/lib/__init__.py b/tests/plugin/lib/__init__.py similarity index 71% rename from tests/integration/lib/__init__.py rename to tests/plugin/lib/__init__.py index 6ac87f3..a4a0ef6 100644 --- a/tests/integration/lib/__init__.py +++ b/tests/plugin/lib/__init__.py @@ -2,4 +2,4 @@ # # SPDX-License-Identifier: BSL-1.0 -"""Shared helpers for integration tests.""" +"""Shared helpers for plugin tests.""" diff --git a/tests/integration/lib/docker_exec.py b/tests/plugin/lib/docker_exec.py similarity index 100% rename from tests/integration/lib/docker_exec.py rename to tests/plugin/lib/docker_exec.py diff --git a/tests/integration/lib/gh_repo.py b/tests/plugin/lib/gh_repo.py similarity index 100% rename from tests/integration/lib/gh_repo.py rename to tests/plugin/lib/gh_repo.py diff --git a/tests/integration/lib/http.py b/tests/plugin/lib/http.py similarity index 100% rename from tests/integration/lib/http.py rename to tests/plugin/lib/http.py diff --git a/tests/integration/lib/weblate_api.py b/tests/plugin/lib/weblate_api.py similarity index 99% rename from tests/integration/lib/weblate_api.py rename to tests/plugin/lib/weblate_api.py index 4ab49d2..0c0f963 100644 --- a/tests/integration/lib/weblate_api.py +++ b/tests/plugin/lib/weblate_api.py @@ -17,7 +17,7 @@ from urllib.parse import urlparse from urllib.request import Request, urlopen -from tests.integration.lib.http import auth_header, base_url, http_json +from tests.plugin.lib.http import auth_header, base_url, http_json # Weblate blocks localhost/loopback in project.web (SSRF protection). _DEFAULT_PROJECT_WEB = "https://example.com/" @@ -513,7 +513,7 @@ def poll_celery_task( interval: float = 3.0, ) -> Any: """Poll Celery task result inside the Weblate container.""" - from tests.integration.lib.docker_exec import docker_exec_python + from tests.plugin.lib.docker_exec import docker_exec_python deadline = time.monotonic() + timeout snippet_template = """ diff --git a/tests/integration/test_auth.py b/tests/plugin/test_auth.py similarity index 96% rename from tests/integration/test_auth.py rename to tests/plugin/test_auth.py index cea06cb..22dd71f 100644 --- a/tests/integration/test_auth.py +++ b/tests/plugin/test_auth.py @@ -15,9 +15,9 @@ import pytest -from tests.integration.lib.http import http_get, http_json +from tests.plugin.lib.http import http_get, http_json -pytestmark = pytest.mark.integration +pytestmark = pytest.mark.plugin _VALID_ADD_OR_UPDATE_BODY = { "organization": "test-org", diff --git a/tests/integration/test_functional.py b/tests/plugin/test_functional.py similarity index 97% rename from tests/integration/test_functional.py rename to tests/plugin/test_functional.py index cc924fa..a17627a 100644 --- a/tests/integration/test_functional.py +++ b/tests/plugin/test_functional.py @@ -15,17 +15,17 @@ import pytest -from tests.integration.conftest import ( +from tests.plugin.conftest import ( FIXTURES_DIR, TEST_BRANCH, TEST_LANG_CODE, TEST_VERSION, ) -from tests.integration.lib.gh_repo import EphemeralGitHubRepo -from tests.integration.lib.http import http_json -from tests.integration.lib.weblate_api import WeblateAPI +from tests.plugin.lib.gh_repo import EphemeralGitHubRepo +from tests.plugin.lib.http import http_json +from tests.plugin.lib.weblate_api import WeblateAPI -pytestmark = pytest.mark.integration +pytestmark = pytest.mark.plugin QBK_FIXTURE = FIXTURES_DIR / "quickbook_fixture.qbk" KNOWN_SOURCE_STRING = "Complex QuickBook test fixture" diff --git a/tests/integration/test_rate_limit.py b/tests/plugin/test_rate_limit.py similarity index 95% rename from tests/integration/test_rate_limit.py rename to tests/plugin/test_rate_limit.py index 38e762d..1052266 100644 --- a/tests/integration/test_rate_limit.py +++ b/tests/plugin/test_rate_limit.py @@ -11,9 +11,9 @@ import pytest -from tests.integration.lib.http import http_get_with_headers, http_json_with_headers +from tests.plugin.lib.http import http_get_with_headers, http_json_with_headers -pytestmark = pytest.mark.integration +pytestmark = pytest.mark.plugin _VALID_ADD_OR_UPDATE_BODY = { "organization": "test-org", diff --git a/tests/integration/test_smoke.py b/tests/plugin/test_smoke.py similarity index 97% rename from tests/integration/test_smoke.py rename to tests/plugin/test_smoke.py index 2761785..620105c 100644 --- a/tests/integration/test_smoke.py +++ b/tests/plugin/test_smoke.py @@ -18,9 +18,9 @@ import pytest -from tests.integration.lib.http import http_get +from tests.plugin.lib.http import http_get -pytestmark = pytest.mark.integration +pytestmark = pytest.mark.plugin # --------------------------------------------------------------------------- # P0: Container boot + plugin load diff --git a/uv.lock b/uv.lock index ec9d00f..b21a62d 100644 --- a/uv.lock +++ b/uv.lock @@ -708,14 +708,14 @@ dev = [ {name = "coverage"}, {name = "pytest-cov"} ] -integration = [ - {name = "pytest"}, - {name = "pytest-timeout"} -] lint = [ {name = "prek"}, {name = "pytest"} ] +plugin = [ + {name = "pytest"}, + {name = "pytest-timeout"} +] pre-commit = [ {name = "prek"}, {name = "pytest"} @@ -740,14 +740,14 @@ dev = [ {name = "coverage", extras = ["toml"], specifier = ">=7.6.0"}, {name = "pytest-cov", specifier = ">=7.1.0"} ] -integration = [ - {name = "pytest", specifier = ">=8.3"}, - {name = "pytest-timeout", specifier = ">=2.3.1"} -] lint = [ {name = "prek", specifier = "==0.3.13"}, {name = "pytest", specifier = ">=8.3"} ] +plugin = [ + {name = "pytest", specifier = ">=8.3"}, + {name = "pytest-timeout", specifier = ">=2.3.1"} +] pre-commit = [ {name = "prek", specifier = "==0.3.13"}, {name = "pytest", specifier = ">=8.3"} From 405f5a1ce46663c492003380143035daadf5f706 Mon Sep 17 00:00:00 2001 From: whisper67265 Date: Mon, 1 Jun 2026 15:53:45 -0600 Subject: [PATCH 3/6] rename again --- .github/README.md | 6 +++--- .github/workflows/ci-plugin-auth.yml | 4 ++-- .github/workflows/ci-plugin-functional.yml | 4 ++-- .github/workflows/ci-plugin-smoke.yml | 4 ++-- README.md | 12 ++++++------ docs/boost-weblate-plugin-refactor-plan.md | 10 +++++----- scripts/README.md | 6 ++++-- scripts/{integration-auth.sh => plugin-auth.sh} | 2 +- ...ntegration-functional.sh => plugin-functional.sh} | 2 +- scripts/{integration-smoke.sh => plugin-smoke.sh} | 2 +- src/boost_weblate/endpoint/apps.py | 4 ++-- tests/plugin/lib/gh_repo.py | 6 +++--- tests/plugin/lib/http.py | 2 +- tests/plugin/lib/weblate_api.py | 2 +- tests/plugin/test_auth.py | 2 +- tests/plugin/test_functional.py | 2 +- tests/plugin/test_rate_limit.py | 2 +- tests/plugin/test_smoke.py | 2 +- 18 files changed, 38 insertions(+), 36 deletions(-) rename scripts/{integration-auth.sh => plugin-auth.sh} (97%) rename scripts/{integration-functional.sh => plugin-functional.sh} (97%) rename scripts/{integration-smoke.sh => plugin-smoke.sh} (97%) diff --git a/.github/README.md b/.github/README.md index 952fe01..1fbe194 100644 --- a/.github/README.md +++ b/.github/README.md @@ -18,9 +18,9 @@ GitHub Actions and CI/CD helpers for this repository. | [`workflows/ci-test.yml`](workflows/ci-test.yml) | Unit tests and coverage | | [`workflows/ci-package.yml`](workflows/ci-package.yml) | Build and package checks | | [`workflows/ci-dependencies.yml`](workflows/ci-dependencies.yml) | Dependency and license audit | -| [`workflows/ci-plugin-smoke.yml`](workflows/ci-plugin-smoke.yml) | Integration smoke (Docker stack) | -| [`workflows/ci-plugin-functional.yml`](workflows/ci-plugin-functional.yml) | Integration functional tests | -| [`workflows/ci-plugin-auth.yml`](workflows/ci-plugin-auth.yml) | Integration auth tests | +| [`workflows/ci-plugin-smoke.yml`](workflows/ci-plugin-smoke.yml) | Plugin smoke (Docker stack) | +| [`workflows/ci-plugin-functional.yml`](workflows/ci-plugin-functional.yml) | Plugin functional tests | +| [`workflows/ci-plugin-auth.yml`](workflows/ci-plugin-auth.yml) | Plugin auth tests | Callable workflows (`ci-*`, `ci-plugin-*`) are triggered only via `workflow_call` from `ci.yml`, not directly on push. diff --git a/.github/workflows/ci-plugin-auth.yml b/.github/workflows/ci-plugin-auth.yml index de582c2..68db4d3 100644 --- a/.github/workflows/ci-plugin-auth.yml +++ b/.github/workflows/ci-plugin-auth.yml @@ -32,8 +32,8 @@ jobs: with: version: 0.11.12 - - name: Run integration auth tests - run: bash scripts/integration-auth.sh + - name: Run plugin auth tests + run: bash scripts/plugin-auth.sh - name: Upload logs on failure if: failure() diff --git a/.github/workflows/ci-plugin-functional.yml b/.github/workflows/ci-plugin-functional.yml index 840ead6..29c6857 100644 --- a/.github/workflows/ci-plugin-functional.yml +++ b/.github/workflows/ci-plugin-functional.yml @@ -42,10 +42,10 @@ jobs: with: version: 0.11.12 - - name: Run integration functional tests + - name: Run plugin functional tests env: GH_TEST_REPO_TOKEN: ${{ secrets.GH_TEST_REPO_TOKEN }} - run: bash scripts/integration-functional.sh + run: bash scripts/plugin-functional.sh - name: Upload logs on failure if: failure() diff --git a/.github/workflows/ci-plugin-smoke.yml b/.github/workflows/ci-plugin-smoke.yml index 04233df..051bccf 100644 --- a/.github/workflows/ci-plugin-smoke.yml +++ b/.github/workflows/ci-plugin-smoke.yml @@ -32,8 +32,8 @@ jobs: with: version: 0.11.12 - - name: Run integration smoke tests - run: bash scripts/integration-smoke.sh + - name: Run plugin smoke tests + run: bash scripts/plugin-smoke.sh - name: Upload logs on failure if: failure() diff --git a/README.md b/README.md index 3c488fa..46e6fca 100644 --- a/README.md +++ b/README.md @@ -265,9 +265,9 @@ Triggered on push and PR to `main` and `develop`. Calls seven reusable sub-workf | `test` | [`.github/workflows/ci-test.yml`](.github/workflows/ci-test.yml) | pytest + 90% coverage gate (`--cov-fail-under=90`) | | `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) | -| `plugin-smoke` | [`.github/workflows/ci-plugin-smoke.yml`](.github/workflows/ci-plugin-smoke.yml) | Docker stack → P0 smoke tests (`scripts/integration-smoke.sh`) | -| `plugin-auth` | [`.github/workflows/ci-plugin-auth.yml`](.github/workflows/ci-plugin-auth.yml) | Docker stack → auth tests (`scripts/integration-auth.sh`) | -| `plugin-functional` | [`.github/workflows/ci-plugin-functional.yml`](.github/workflows/ci-plugin-functional.yml) | Docker stack → E2E functional tests (`scripts/integration-functional.sh`); optional `GH_TEST_REPO_TOKEN` secret for GitHub-backed tests | +| `plugin-smoke` | [`.github/workflows/ci-plugin-smoke.yml`](.github/workflows/ci-plugin-smoke.yml) | Docker stack → P0 smoke tests (`scripts/plugin-smoke.sh`) | +| `plugin-auth` | [`.github/workflows/ci-plugin-auth.yml`](.github/workflows/ci-plugin-auth.yml) | Docker stack → auth tests (`scripts/plugin-auth.sh`) | +| `plugin-functional` | [`.github/workflows/ci-plugin-functional.yml`](.github/workflows/ci-plugin-functional.yml) | Docker stack → E2E functional tests (`scripts/plugin-functional.sh`); optional `GH_TEST_REPO_TOKEN` secret for GitHub-backed tests | All `ci-plugin-*` jobs build the CI Docker stack (`docker/docker-compose.ci.yml`), wait for the healthcheck, create an API token, run the corresponding pytest suite under `tests/plugin/`, and tear down. @@ -281,15 +281,15 @@ Full deployment procedure: [docs/deployment-runbook.md](docs/deployment-runbook. ```bash # Smoke (P0 — container boot, format registration, URL registration): -bash scripts/integration-smoke.sh +bash scripts/plugin-smoke.sh # Auth (token auth on protected routes; ping stays public): -bash scripts/integration-auth.sh +bash scripts/plugin-auth.sh # Functional (QuickBook round-trip, BoostComponentService E2E, Celery flow): # Set GH_TEST_REPO_TOKEN for GitHub-backed tests; unset to skip them. export GH_TEST_REPO_TOKEN=ghp_... -bash scripts/integration-functional.sh +bash scripts/plugin-functional.sh ``` Each script builds `docker/docker-compose.ci.yml`, waits for health, runs its pytest suite, and tears down the stack. diff --git a/docs/boost-weblate-plugin-refactor-plan.md b/docs/boost-weblate-plugin-refactor-plan.md index 55182c4..e787e8b 100644 --- a/docs/boost-weblate-plugin-refactor-plan.md +++ b/docs/boost-weblate-plugin-refactor-plan.md @@ -72,7 +72,7 @@ cppa-weblate-plugin/ |-------------|----------| | Test coverage | ≥ 90 % (enforced by `--cov-fail-under=90`) | | CI | GitHub Actions: `pytest`, `ruff check`, coverage gate on every PR | -| Integration test | CI job: `uv pip install weblate "git+https://…/cppa-weblate-plugin@HEAD"` in a Docker-compose stack → smoke-test endpoint + format registration | +| Plugin test | CI job: `uv pip install weblate "git+https://…/cppa-weblate-plugin@HEAD"` in a Docker-compose stack → smoke-test endpoint + format registration | | CD | CD workflow clones **upstream** `weblate-docker`, edits its `Dockerfile` (install upstream Weblate from PyPI + plugin from `git+https://…/cppa-weblate-plugin@`, `COPY` `settings-override.py` into the image). `settings-override.py` is versioned in **this** repo. `WEBLATE_ADD_APPS` adds `boost_weblate.endpoint` to `INSTALLED_APPS`; `WEBLATE_FORMATS +=` registers the QuickBook format class. | | LICENSE | GPL-3.0-or-later in repo root (same license family as Weblate) | | Runtime deps | All deps declared in `pyproject.toml`; no implicit system deps without docs | @@ -108,7 +108,7 @@ Weblate's `urls.py` does **not** auto-discover URL patterns from `INSTALLED_APPS - `AppConfig.ready()` — programmatically append to `urlpatterns` at startup (simplest for a self-contained plugin), **or** - `ROOT_URLCONF` override in `settings-override.py` — point to a custom URL conf that imports Weblate's patterns and adds the plugin's `include()`. -**Action:** prototype both approaches in the integration test environment and pick the one that survives Weblate upgrades best. Document the chosen approach in `README.md` so Week 2 implementation can proceed without ambiguity. +**Action:** prototype both approaches in the plugin test environment and pick the one that survives Weblate upgrades best. Document the chosen approach in `README.md` so Week 2 implementation can proceed without ambiguity. --- @@ -136,7 +136,7 @@ The `AppConfig`, URL config, and settings registration skeletons are in [Appendi **Weekly outcome:** **`integration.yml`** is fully working (no partial phases); **`docs/deployment-runbook.md`** written once to match the live CD path; CD script and release tag; fork retired. -## Integration CI — complete in this week +## Plugin CI — complete in this week - Replace the **`integration.yml`** placeholder with a finished workflow: Docker Compose stack, `uv pip install weblate "git+https://…/cppa-weblate-plugin@HEAD"`, smoke-test format registration and `/boost-endpoint/`. @@ -166,8 +166,8 @@ The standalone plugin approach is the only alternative that satisfies all six cr | ID | Risk | Likelihood | Impact | Mitigation | |----|------|-----------|--------|------------| -| R1 | `BaseFormat` ABC changes between Weblate releases, breaking `QuickBookFormat` | Low — it is the public extension API | High | Pin Weblate version in `pyproject.toml`; integration CI installs both packages on every PR; bumping the pin is a deliberate, tested action | -| R2 | Weblate removes or renames `WEBLATE_FORMATS` / `WEBLATE_ADD_APPS` Docker env vars | Very low — these are documented deployment knobs | High | Same pin + integration CI; if removed, `settings-override.py` `COPY` path is a fallback requiring only a `Dockerfile` edit | +| R1 | `BaseFormat` ABC changes between Weblate releases, breaking `QuickBookFormat` | Low — it is the public extension API | High | Pin Weblate version in `pyproject.toml`; plugin CI installs both packages on every PR; bumping the pin is a deliberate, tested action | +| R2 | Weblate removes or renames `WEBLATE_FORMATS` / `WEBLATE_ADD_APPS` Docker env vars | Very low — these are documented deployment knobs | High | Same pin + plugin CI; if removed, `settings-override.py` `COPY` path is a fallback requiring only a `Dockerfile` edit | | R3 | Django major-version upgrade inside Weblate breaks `boost_endpoint` views/serializers | Low — Django REST patterns are stable across majors | Medium | Standard Django upgrade path applies; no Weblate-internal Django usage in the plugin | | R4 | `git+https://…@` install fails in air-gapped or restricted network environments | Medium — depends on deployment environment | Medium | Mirror the plugin package to an internal PyPI index if required; the install mechanism is standard pip and switchable | | R5 | Scope creep requiring Weblate internal API access (e.g. signals, celery tasks) | Low given current requirements | High | Any new requirement that cannot be satisfied via documented extension points is a no-go trigger | diff --git a/scripts/README.md b/scripts/README.md index efd6062..c517d57 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -10,13 +10,15 @@ Reusable shell scripts for CI and CD. - **lib/compose.sh** — Sets `COMPOSE_FILE`, `COMPOSE_PROJECT_NAME`, exports `compose()` wrapper. - **lib/weblate-stack.sh** — Stack lifecycle functions: `stack_build`, `stack_up`, `stack_wait_healthy`, `stack_create_token`, `stack_logs`, `stack_down`. -- **integration-smoke.sh** — CI entrypoint for P0 smoke tests (build, start, health-check, test, teardown). +- **plugin-smoke.sh** — CI entrypoint for P0 smoke tests (build, start, health-check, test, teardown). +- **plugin-auth.sh** — CI entrypoint for auth and rate-limit tests. +- **plugin-functional.sh** — CI entrypoint for E2E functional tests (optional GitHub repo). ## Usage ```bash # Run smoke tests locally: -bash scripts/integration-smoke.sh +bash scripts/plugin-smoke.sh # Source the library for custom workflows: source scripts/lib/weblate-stack.sh diff --git a/scripts/integration-auth.sh b/scripts/plugin-auth.sh similarity index 97% rename from scripts/integration-auth.sh rename to scripts/plugin-auth.sh index 8361c68..d129972 100755 --- a/scripts/integration-auth.sh +++ b/scripts/plugin-auth.sh @@ -2,7 +2,7 @@ # SPDX-FileCopyrightText: 2026 Andrew Zhang # SPDX-License-Identifier: BSL-1.0 -# Integration auth test entrypoint. +# Plugin auth test entrypoint. # Builds the stack, waits for health, creates a token, runs auth tests. # On exit (success or failure): collects logs and tears down the stack. diff --git a/scripts/integration-functional.sh b/scripts/plugin-functional.sh similarity index 97% rename from scripts/integration-functional.sh rename to scripts/plugin-functional.sh index f43cf33..dacc6c4 100755 --- a/scripts/integration-functional.sh +++ b/scripts/plugin-functional.sh @@ -2,7 +2,7 @@ # SPDX-FileCopyrightText: 2026 Andrew Zhang # SPDX-License-Identifier: BSL-1.0 -# Integration functional test entrypoint (P1). +# Plugin functional test entrypoint (P1). # Builds the stack, waits for health, creates API token, extracts SSH pubkey, # runs functional tests against a live Weblate instance. diff --git a/scripts/integration-smoke.sh b/scripts/plugin-smoke.sh similarity index 97% rename from scripts/integration-smoke.sh rename to scripts/plugin-smoke.sh index 3910857..403b64e 100755 --- a/scripts/integration-smoke.sh +++ b/scripts/plugin-smoke.sh @@ -2,7 +2,7 @@ # SPDX-FileCopyrightText: 2026 Andrew Zhang # SPDX-License-Identifier: BSL-1.0 -# Integration smoke test entrypoint. +# Plugin smoke test entrypoint. # Builds the stack, waits for health, creates a token, runs smoke tests. # On exit (success or failure): collects logs and tears down the stack. diff --git a/src/boost_weblate/endpoint/apps.py b/src/boost_weblate/endpoint/apps.py index c36bb20..07717e1 100644 --- a/src/boost_weblate/endpoint/apps.py +++ b/src/boost_weblate/endpoint/apps.py @@ -17,7 +17,7 @@ def register_plugin_urls() -> None: """Append this app's routes to Weblate's pattern list. - This is the supported integration path: at process + This is the supported plugin path: at process startup, append a single ``path("boost-endpoint/", ...)`` entry to ``weblate.urls.real_patterns`` so routes stay under Weblate's ``URL_PREFIX`` handling. @@ -26,7 +26,7 @@ def register_plugin_urls() -> None: ``add-or-update/``, and ``plugin-ping/`` (see ``boost_weblate.endpoint.urls``). Weblate builds ``urlpatterns`` from module-level ``real_patterns`` (see - ``weblate.urls``). Optional integrations append to ``real_patterns`` before + ``weblate.urls``). Optional plugins append to ``real_patterns`` before the ``URL_PREFIX`` wrapper is applied, so mutating that list keeps routes consistent when a path prefix is configured. """ diff --git a/tests/plugin/lib/gh_repo.py b/tests/plugin/lib/gh_repo.py index 8843c18..fe92e87 100644 --- a/tests/plugin/lib/gh_repo.py +++ b/tests/plugin/lib/gh_repo.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: BSL-1.0 -"""Ephemeral GitHub repository lifecycle for integration tests (stdlib only).""" +"""Ephemeral GitHub repository lifecycle for plugin tests (stdlib only).""" from __future__ import annotations @@ -23,7 +23,7 @@ def _api_error_message(method: str, path: str, code: int, raw: bytes) -> str: class EphemeralGitHubRepo: - """Create, populate, and destroy a temporary GitHub repo for integration tests.""" + """Create, populate, and destroy a temporary GitHub repo for plugin tests.""" __test__ = False # not a pytest test class @@ -161,7 +161,7 @@ def push_fixtures(self, fixture_dir: Path, branch: str) -> None: dest, src.read_bytes(), branch, - message=f"Add {dest} for integration tests", + message=f"Add {dest} for plugin tests", ) def add_deploy_key(self, public_key: str, title: str = "weblate-ci") -> None: diff --git a/tests/plugin/lib/http.py b/tests/plugin/lib/http.py index 2a6b3b3..1ce69f1 100644 --- a/tests/plugin/lib/http.py +++ b/tests/plugin/lib/http.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: BSL-1.0 -"""HTTP helper for integration tests — stdlib only (no requests/httpx).""" +"""HTTP helper for plugin tests — stdlib only (no requests/httpx).""" from __future__ import annotations diff --git a/tests/plugin/lib/weblate_api.py b/tests/plugin/lib/weblate_api.py index 0c0f963..17cab6c 100644 --- a/tests/plugin/lib/weblate_api.py +++ b/tests/plugin/lib/weblate_api.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: BSL-1.0 -"""Weblate REST API client for integration tests (stdlib only).""" +"""Weblate REST API client for plugin tests (stdlib only).""" from __future__ import annotations diff --git a/tests/plugin/test_auth.py b/tests/plugin/test_auth.py index 22dd71f..9c6ff21 100644 --- a/tests/plugin/test_auth.py +++ b/tests/plugin/test_auth.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: BSL-1.0 -"""P2 integration auth tests. +"""P2 plugin auth tests. Verifies authentication and authorization behavior across all Boost endpoint routes: diff --git a/tests/plugin/test_functional.py b/tests/plugin/test_functional.py index a17627a..185b7c7 100644 --- a/tests/plugin/test_functional.py +++ b/tests/plugin/test_functional.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: BSL-1.0 -"""P1 integration functional tests. +"""P1 plugin functional tests. Requires a live Weblate stack (Docker Compose) and optional GH_TEST_REPO_TOKEN for add-or-update / BoostComponentService tests. diff --git a/tests/plugin/test_rate_limit.py b/tests/plugin/test_rate_limit.py index 1052266..e8bf6fd 100644 --- a/tests/plugin/test_rate_limit.py +++ b/tests/plugin/test_rate_limit.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: BSL-1.0 -"""Integration tests for Boost endpoint rate limiting.""" +"""Plugin tests for Boost endpoint rate limiting.""" from __future__ import annotations diff --git a/tests/plugin/test_smoke.py b/tests/plugin/test_smoke.py index 620105c..bb8be36 100644 --- a/tests/plugin/test_smoke.py +++ b/tests/plugin/test_smoke.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: BSL-1.0 -"""P0 integration smoke tests. +"""P0 plugin smoke tests. Verifies: - Container boots with plugin installed (no import errors, no AppRegistryNotReady) From aabac7dd638cfc8ae63fa4dad70d44641728263a Mon Sep 17 00:00:00 2001 From: whisper67265 Date: Mon, 1 Jun 2026 16:03:01 -0600 Subject: [PATCH 4/6] fix ci fail --- tests/plugin/test_rate_limit.py | 42 ++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/tests/plugin/test_rate_limit.py b/tests/plugin/test_rate_limit.py index e8bf6fd..9d43447 100644 --- a/tests/plugin/test_rate_limit.py +++ b/tests/plugin/test_rate_limit.py @@ -32,23 +32,51 @@ def _parse_rate_limit(rate: str) -> int: return int(match.group(1)) +_RATE_LIMIT_USER_SNIPPET = """ +from weblate.auth.models import User +from rest_framework.authtoken.models import Token +from weblate.utils.token import get_token + +u, _ = User.objects.get_or_create( + username="plugin_ratelimit", + defaults={"email": "plugin-ratelimit@test.invalid"}, +) +Token.objects.filter(user=u).delete() +t = Token.objects.create(user=u, key=get_token("wlu")) +print(t.key) +""" + + +@pytest.fixture(scope="module") +def rate_limit_api_token() -> str: + """Dedicated user so auth tests on admin do not consume scoped throttle budget.""" + token = os.environ.get("WEBLATE_RATE_LIMIT_API_TOKEN", "").strip() + if token: + return token + from tests.plugin.lib.docker_exec import docker_exec_python + + return docker_exec_python(_RATE_LIMIT_USER_SNIPPET.strip()) + + class TestBoostEndpointRateLimit: """Live-stack rate limit enforcement for Boost endpoint routes.""" - def test_info_returns_429_when_rate_limited(self, api_token: str) -> None: + def test_info_returns_429_when_rate_limited( + self, rate_limit_api_token: str + ) -> None: rate = os.environ.get("BOOST_ENDPOINT_THROTTLE_INFO", "3/minute") limit = _parse_rate_limit(rate) last_headers: dict[str, str] = {} for _ in range(limit): code, _body, headers = http_get_with_headers( - "/boost-endpoint/info/", token=api_token + "/boost-endpoint/info/", token=rate_limit_api_token ) assert code == 200, f"expected 200 before limit: {code}" last_headers = headers code, _body, headers = http_get_with_headers( - "/boost-endpoint/info/", token=api_token + "/boost-endpoint/info/", token=rate_limit_api_token ) assert code == 429, f"expected 429 after {limit} requests: {code}" retry_after = headers.get("Retry-After") @@ -58,7 +86,9 @@ def test_info_returns_429_when_rate_limited(self, api_token: str) -> None: if "X-RateLimit-Limit" in last_headers: assert int(last_headers["X-RateLimit-Limit"]) == limit - def test_add_or_update_returns_429_when_rate_limited(self, api_token: str) -> None: + def test_add_or_update_returns_429_when_rate_limited( + self, rate_limit_api_token: str + ) -> None: rate = os.environ.get("BOOST_ENDPOINT_THROTTLE_ADD_OR_UPDATE", "3/hour") limit = _parse_rate_limit(rate) @@ -66,7 +96,7 @@ def test_add_or_update_returns_429_when_rate_limited(self, api_token: str) -> No code, _body, _headers = http_json_with_headers( "POST", "/boost-endpoint/add-or-update/", - token=api_token, + token=rate_limit_api_token, body=_VALID_ADD_OR_UPDATE_BODY, ) assert code == 202, f"expected 202 before limit: {code}" @@ -74,7 +104,7 @@ def test_add_or_update_returns_429_when_rate_limited(self, api_token: str) -> No code, _body, headers = http_json_with_headers( "POST", "/boost-endpoint/add-or-update/", - token=api_token, + token=rate_limit_api_token, body=_VALID_ADD_OR_UPDATE_BODY, ) assert code == 429, f"expected 429 after {limit} requests: {code}" From 0b7045a9ddf7bae96e1140e63892c227d0208b5c Mon Sep 17 00:00:00 2001 From: whisper67265 Date: Mon, 1 Jun 2026 16:31:49 -0600 Subject: [PATCH 5/6] fix ci fail --- tests/plugin/lib/http.py | 10 +++++++- tests/plugin/test_rate_limit.py | 42 ++++++++++++++++++++++++++------- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/tests/plugin/lib/http.py b/tests/plugin/lib/http.py index 1ce69f1..cf1da70 100644 --- a/tests/plugin/lib/http.py +++ b/tests/plugin/lib/http.py @@ -23,7 +23,15 @@ def auth_header(token: str) -> str: def _response_headers(header_obj) -> dict[str, str]: - return {k: v for k, v in header_obj.items()} + """Normalize header names to lowercase for case-insensitive lookup.""" + if header_obj is None: + return {} + return {k.lower(): v for k, v in header_obj.items()} + + +def get_response_header(headers: dict[str, str], name: str) -> str | None: + """Return a response header value (case-insensitive).""" + return headers.get(name.lower()) def http_json_with_headers( diff --git a/tests/plugin/test_rate_limit.py b/tests/plugin/test_rate_limit.py index 9d43447..0a79cd6 100644 --- a/tests/plugin/test_rate_limit.py +++ b/tests/plugin/test_rate_limit.py @@ -11,7 +11,11 @@ import pytest -from tests.plugin.lib.http import http_get_with_headers, http_json_with_headers +from tests.plugin.lib.http import ( + get_response_header, + http_get_with_headers, + http_json_with_headers, +) pytestmark = pytest.mark.plugin @@ -22,6 +26,7 @@ } _RATE_PATTERN = re.compile(r"^(\d+)/(minute|hour|min|h|day|d)$") +_RETRY_AFTER_IN_DETAIL = re.compile(r"Expected available in (\d+) second", re.I) def _parse_rate_limit(rate: str) -> int: @@ -32,6 +37,21 @@ def _parse_rate_limit(rate: str) -> int: return int(match.group(1)) +def _retry_after_seconds(headers: dict[str, str], body: object) -> int | None: + """Retry-After header, or seconds parsed from DRF Throttled error detail.""" + header = get_response_header(headers, "Retry-After") + if header is not None: + return int(header) + if isinstance(body, dict): + errors = body.get("errors") or [] + if errors: + detail = str(errors[0].get("detail", "")) + match = _RETRY_AFTER_IN_DETAIL.search(detail) + if match: + return int(match.group(1)) + return None + + _RATE_LIMIT_USER_SNIPPET = """ from weblate.auth.models import User from rest_framework.authtoken.models import Token @@ -79,12 +99,14 @@ def test_info_returns_429_when_rate_limited( "/boost-endpoint/info/", token=rate_limit_api_token ) assert code == 429, f"expected 429 after {limit} requests: {code}" - retry_after = headers.get("Retry-After") - assert retry_after is not None - assert int(retry_after) > 0 + retry_after = _retry_after_seconds(headers, _body) + assert retry_after is not None, ( + f"expected Retry-After header or wait in body, headers={sorted(headers)}" + ) + assert retry_after > 0 - if "X-RateLimit-Limit" in last_headers: - assert int(last_headers["X-RateLimit-Limit"]) == limit + if get_response_header(last_headers, "X-RateLimit-Limit") is not None: + assert int(get_response_header(last_headers, "X-RateLimit-Limit")) == limit def test_add_or_update_returns_429_when_rate_limited( self, rate_limit_api_token: str @@ -108,6 +130,8 @@ def test_add_or_update_returns_429_when_rate_limited( body=_VALID_ADD_OR_UPDATE_BODY, ) assert code == 429, f"expected 429 after {limit} requests: {code}" - retry_after = headers.get("Retry-After") - assert retry_after is not None - assert int(retry_after) > 0 + retry_after = _retry_after_seconds(headers, _body) + assert retry_after is not None, ( + f"expected Retry-After header or wait in body, headers={sorted(headers)}" + ) + assert retry_after > 0 From a7abb0a6065d5060cb7e0c6973d7d587e3237500 Mon Sep 17 00:00:00 2001 From: whisper67265 Date: Mon, 1 Jun 2026 16:49:55 -0600 Subject: [PATCH 6/6] fix coderabbitai review --- tests/endpoint/test_views.py | 80 +++++++++++++++++++-------------- tests/test_settings_override.py | 9 ++-- 2 files changed, 53 insertions(+), 36 deletions(-) diff --git a/tests/endpoint/test_views.py b/tests/endpoint/test_views.py index 718454d..6787f23 100644 --- a/tests/endpoint/test_views.py +++ b/tests/endpoint/test_views.py @@ -5,6 +5,7 @@ from __future__ import annotations import importlib.metadata +from contextlib import contextmanager from copy import deepcopy from unittest.mock import MagicMock @@ -34,11 +35,6 @@ } -def _reload_throttle_rates() -> None: - api_settings.reload() - SimpleRateThrottle.THROTTLE_RATES = api_settings.DEFAULT_THROTTLE_RATES - - def _throttle_rest_framework(**rate_overrides: str) -> dict: rf = deepcopy(settings.REST_FRAMEWORK) rates = dict(rf.get("DEFAULT_THROTTLE_RATES", {})) @@ -47,6 +43,45 @@ def _throttle_rest_framework(**rate_overrides: str) -> dict: return rf +@contextmanager +def _isolated_throttle_rates(rest_framework: dict): + """Apply REST_FRAMEWORK throttle rates; restore rates and cache after use.""" + 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 scoped_low_throttle_rates(): + rest_framework = _throttle_rest_framework( + user="10000/hour", + info="2/minute", + **{"add-or-update": "2/minute"}, + ) + with _isolated_throttle_rates(rest_framework): + yield + + +@pytest.fixture +def user_low_throttle_rates(): + rest_framework = _throttle_rest_framework( + user="2/minute", + info="10000/minute", + ) + with _isolated_throttle_rates(rest_framework): + yield + + @pytest.fixture def weblate_anonymous_user_no_db(monkeypatch: pytest.MonkeyPatch) -> None: """Weblate's default anonymous user loads from DB; tests do not run migrations.""" @@ -231,17 +266,9 @@ def process_all(self, _submodules, *, user, request=None): # noqa: ANN001 ) -@override_settings( - REST_FRAMEWORK=_throttle_rest_framework( - user="10000/hour", - info="2/minute", - **{"add-or-update": "2/minute"}, - ) -) -def test_boost_endpoint_info_returns_429_when_scoped_throttled() -> None: - cache.clear() - _reload_throttle_rates() - +def test_boost_endpoint_info_returns_429_when_scoped_throttled( + scoped_low_throttle_rates, +) -> None: factory = APIRequestFactory() user = User(username="t_throttle_info", pk=101) view = BoostEndpointInfo.as_view() @@ -260,19 +287,10 @@ def test_boost_endpoint_info_returns_429_when_scoped_throttled() -> None: assert int(response["Retry-After"]) > 0 -@override_settings( - REST_FRAMEWORK=_throttle_rest_framework( - user="10000/hour", - info="2/minute", - **{"add-or-update": "2/minute"}, - ) -) def test_add_or_update_returns_429_when_scoped_throttled( + scoped_low_throttle_rates, monkeypatch: pytest.MonkeyPatch, ) -> None: - cache.clear() - _reload_throttle_rates() - delay_mock = MagicMock(return_value=MagicMock(id="task-uuid")) monkeypatch.setattr( "boost_weblate.endpoint.views.boost_add_or_update_task.delay", @@ -298,13 +316,9 @@ def test_add_or_update_returns_429_when_scoped_throttled( assert delay_mock.call_count == 2 -@override_settings( - REST_FRAMEWORK=_throttle_rest_framework(user="2/minute", info="10000/minute") -) -def test_boost_endpoint_info_user_throttle_can_429() -> None: - cache.clear() - _reload_throttle_rates() - +def test_boost_endpoint_info_user_throttle_can_429( + user_low_throttle_rates, +) -> None: factory = APIRequestFactory() user = User(username="t_user_throttle", pk=103) view = BoostEndpointInfo.as_view() diff --git a/tests/test_settings_override.py b/tests/test_settings_override.py index 7ecff53..92f8818 100644 --- a/tests/test_settings_override.py +++ b/tests/test_settings_override.py @@ -94,7 +94,10 @@ def test_weblate_formats_includes_upstream_and_quickbook() -> None: def test_merge_boost_endpoint_throttle_rates_preserves_upstream() -> None: - from boost_weblate.settings_override import merge_boost_endpoint_throttle_rates + from boost_weblate.settings_override import ( + BOOST_ENDPOINT_THROTTLE_RATES, + merge_boost_endpoint_throttle_rates, + ) merged = merge_boost_endpoint_throttle_rates( {"DEFAULT_THROTTLE_RATES": {"user": "1/hour", "anon": "100/day"}} @@ -102,5 +105,5 @@ def test_merge_boost_endpoint_throttle_rates_preserves_upstream() -> None: rates = merged["DEFAULT_THROTTLE_RATES"] assert rates["user"] == "1/hour" assert rates["anon"] == "100/day" - assert rates["info"] == "60/minute" - assert rates["add-or-update"] == "10/hour" + assert rates["info"] == BOOST_ENDPOINT_THROTTLE_RATES["info"] + assert rates["add-or-update"] == BOOST_ENDPOINT_THROTTLE_RATES["add-or-update"]