Created
January 18, 2026 16:55
-
-
Save mehdibennis/2057981cb373904f7692eface22625c5 to your computer and use it in GitHub Desktop.
Patch for PR Test-implementation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| From 82dd4bf5b8f818aa7e5b4134b590d523287726c0 Mon Sep 17 00:00:00 2001 | |
| From: mehdibennis <medi.b@hotmail.com> | |
| Date: Sun, 18 Jan 2026 17:35:01 +0100 | |
| Subject: [PATCH] Add unit tests for search indexes, serializers, and views | |
| - Implement tests for `strip_accents` function in search indexes. | |
| - Create unit tests for `GuideBookIndex`, `AuthorIndex`, and related methods. | |
| - Add comprehensive tests for serializers including `FilterBookSerializer`, `GuideBookCardSimpleSerializer`, and `SearchSerializer`. | |
| - Introduce complex tests for `GuideBookPageSerializer` and `TopoUnitaireSerializer` covering various scenarios. | |
| - Enhance view tests for search functionality, including filters and similar elements. | |
| - Ensure coverage for POI retrieval and area-related views. | |
| --- | |
| backend/conftest.py | 28 ++ | |
| backend/requirements.txt | 12 +- | |
| backend/tests/test_accounts_functional.py | 57 +++ | |
| backend/tests/test_api_functional.py | 103 +++++ | |
| backend/tests/test_audit_decorators.py | 52 +++ | |
| backend/tests/test_cache_module.py | 39 ++ | |
| backend/tests/test_cache_unit.py | 52 +++ | |
| backend/tests/test_forms_unit.py | 26 ++ | |
| backend/tests/test_globals_unit.py | 44 +++ | |
| backend/tests/test_models_unit.py | 353 ++++++++++++++++++ | |
| backend/tests/test_search_indexes.py | 19 + | |
| backend/tests/test_search_indexes_unit.py | 35 ++ | |
| backend/tests/test_serializers_unit.py | 110 ++++++ | |
| .../tests/test_serializers_unit_complex.py | 256 +++++++++++++ | |
| backend/tests/test_utils.py | 59 +++ | |
| backend/tests/test_views_search_unit.py | 208 +++++++++++ | |
| backend/tests/test_views_search_unit_extra.py | 136 +++++++ | |
| backend/tests/test_views_unit.py | 315 ++++++++++++++++ | |
| 18 files changed, 1903 insertions(+), 1 deletion(-) | |
| create mode 100644 backend/conftest.py | |
| create mode 100644 backend/tests/test_accounts_functional.py | |
| create mode 100644 backend/tests/test_api_functional.py | |
| create mode 100644 backend/tests/test_audit_decorators.py | |
| create mode 100644 backend/tests/test_cache_module.py | |
| create mode 100644 backend/tests/test_cache_unit.py | |
| create mode 100644 backend/tests/test_forms_unit.py | |
| create mode 100644 backend/tests/test_globals_unit.py | |
| create mode 100644 backend/tests/test_models_unit.py | |
| create mode 100644 backend/tests/test_search_indexes.py | |
| create mode 100644 backend/tests/test_search_indexes_unit.py | |
| create mode 100644 backend/tests/test_serializers_unit.py | |
| create mode 100644 backend/tests/test_serializers_unit_complex.py | |
| create mode 100644 backend/tests/test_utils.py | |
| create mode 100644 backend/tests/test_views_search_unit.py | |
| create mode 100644 backend/tests/test_views_search_unit_extra.py | |
| create mode 100644 backend/tests/test_views_unit.py | |
| diff --git a/backend/conftest.py b/backend/conftest.py | |
| new file mode 100644 | |
| index 0000000..0af22fe | |
| --- /dev/null | |
| +++ b/backend/conftest.py | |
| @@ -0,0 +1,28 @@ | |
| +import os | |
| +import shutil | |
| +import tempfile | |
| +import pytest | |
| +from django.conf import settings | |
| + | |
| +@pytest.fixture(scope="session", autouse=True) | |
| +def cleanup_media_root(): | |
| + """ | |
| + Create a temporary directory for media files during tests | |
| + and clean it up afterwards. | |
| + """ | |
| + # Create a temporary directory | |
| + temp_media_root = tempfile.mkdtemp() | |
| + | |
| + # Save original MEDIA_ROOT | |
| + original_media_root = settings.MEDIA_ROOT | |
| + | |
| + # Override MEDIA_ROOT | |
| + settings.MEDIA_ROOT = temp_media_root | |
| + | |
| + yield | |
| + | |
| + # Restore original MEDIA_ROOT (optional, as process ends) | |
| + settings.MEDIA_ROOT = original_media_root | |
| + | |
| + # Remove the temporary directory | |
| + shutil.rmtree(temp_media_root, ignore_errors=True) | |
| diff --git a/backend/requirements.txt b/backend/requirements.txt | |
| index 960547a..38d4830 100644 | |
| --- a/backend/requirements.txt | |
| +++ b/backend/requirements.txt | |
| @@ -21,4 +21,14 @@ django-haystack >=3.3.0 | |
| elasticsearch>=7.0.0,<8.0.0 | |
| celery[redis]>=5.5.2 | |
| django-silk | |
| -pyinstrument | |
| \ No newline at end of file | |
| +pyinstrument | |
| +# Dev tools | |
| +mypy | |
| +django-stubs | |
| +ruff | |
| +bandit | |
| +pytest | |
| +pytest-django | |
| +coverage | |
| +ipython | |
| +whitenoise | |
| diff --git a/backend/tests/test_accounts_functional.py b/backend/tests/test_accounts_functional.py | |
| new file mode 100644 | |
| index 0000000..a821c80 | |
| --- /dev/null | |
| +++ b/backend/tests/test_accounts_functional.py | |
| @@ -0,0 +1,57 @@ | |
| +import uuid | |
| + | |
| +import pytest | |
| +from django.contrib.auth.models import User | |
| +from rest_framework.test import APIClient | |
| + | |
| + | |
| +@pytest.mark.django_db | |
| +def test_register_login_profile_logout(): | |
| + client = APIClient() | |
| + client.defaults["HTTP_HOST"] = "localhost" | |
| + | |
| + name = f"testuser_{uuid.uuid4().hex[:8]}" | |
| + reg_data = {"username": name, "email": f"{name}@example.com", "password": "StrongPass1"} | |
| + | |
| + # Register | |
| + r = client.post("/accounts/register/", reg_data, format="json") | |
| + assert r.status_code == 201 | |
| + | |
| + # Login | |
| + r = client.post("/accounts/login/", {"email": reg_data["email"], "password": reg_data["password"]}, format="json") | |
| + assert r.status_code == 200 | |
| + assert "token" in r.data | |
| + token = r.data["token"] | |
| + | |
| + # Access profile with token | |
| + client.credentials(HTTP_AUTHORIZATION="Token " + token) | |
| + r = client.get("/accounts/user/") | |
| + assert r.status_code == 200 | |
| + assert r.data.get("username") == reg_data["username"] | |
| + assert r.data.get("email") == reg_data["email"] | |
| + | |
| + # Logout | |
| + r = client.post("/accounts/logout/") | |
| + assert r.status_code == 200 | |
| + | |
| + | |
| +@pytest.mark.django_db | |
| +def test_register_existing_user(): | |
| + name = f"existing_{uuid.uuid4().hex[:8]}" | |
| + email = f"{name}@example.com" | |
| + User.objects.create_user(username=name, email=email, password="GoodPass1") | |
| + client = APIClient() | |
| + client.defaults["HTTP_HOST"] = "localhost" | |
| + r = client.post("/accounts/register/", {"username": name, "email": email, "password": "GoodPass1"}, format="json") | |
| + assert r.status_code == 400 | |
| + | |
| + | |
| +@pytest.mark.django_db | |
| +def test_login_wrong_password(): | |
| + name = f"u2_{uuid.uuid4().hex[:8]}" | |
| + email = f"{name}@example.com" | |
| + User.objects.create_user(username=name, email=email, password="GoodPass1") | |
| + client = APIClient() | |
| + client.defaults["HTTP_HOST"] = "localhost" | |
| + r = client.post("/accounts/login/", {"email": email, "password": "badpass"}, format="json") | |
| + assert r.status_code == 401 | |
| diff --git a/backend/tests/test_api_functional.py b/backend/tests/test_api_functional.py | |
| new file mode 100644 | |
| index 0000000..5bdeb33 | |
| --- /dev/null | |
| +++ b/backend/tests/test_api_functional.py | |
| @@ -0,0 +1,103 @@ | |
| +import uuid | |
| + | |
| +import pytest | |
| +from django.contrib.auth.models import User | |
| +from rest_framework.response import Response | |
| +from rest_framework.test import APIClient | |
| + | |
| + | |
| +@pytest.mark.django_db | |
| +def test_search_as_superuser(monkeypatch): | |
| + # create superuser | |
| + name = f"su_{uuid.uuid4().hex[:8]}" | |
| + email = f"{name}@example.com" | |
| + pw = "RootPass1" | |
| + User.objects.create_superuser(username=name, email=email, password=pw) | |
| + | |
| + client = APIClient() | |
| + client.defaults["HTTP_HOST"] = "localhost" | |
| + | |
| + # login to obtain token | |
| + r = client.post("/accounts/login/", {"email": email, "password": pw}, format="json") | |
| + assert r.status_code == 200 | |
| + token = r.data["token"] | |
| + | |
| + client.credentials(HTTP_AUTHORIZATION="Token " + token) | |
| + # monkeypatch the heavy search.get implementation to return quickly | |
| + import topotheque_app.views as views | |
| + | |
| + def _fast_get(self, request, format=None): | |
| + return Response({"results": []}) | |
| + | |
| + monkeypatch.setattr(views.search, "get", _fast_get, raising=True) | |
| + | |
| + r = client.get("/api/topoguides/search?activity_list=climbing") | |
| + assert r.status_code == 200 | |
| + assert "results" in r.data | |
| + | |
| + | |
| +@pytest.mark.django_db | |
| +def test_count_topoguides_authenticated(): | |
| + name = f"u_{uuid.uuid4().hex[:8]}" | |
| + email = f"{name}@example.com" | |
| + pw = "UserPass1" | |
| + User.objects.create_user(username=name, email=email, password=pw) | |
| + | |
| + client = APIClient() | |
| + client.defaults["HTTP_HOST"] = "localhost" | |
| + r = client.post("/accounts/login/", {"email": email, "password": pw}, format="json") | |
| + assert r.status_code == 200 | |
| + token = r.data["token"] | |
| + | |
| + client.credentials(HTTP_AUTHORIZATION="Token " + token) | |
| + r = client.get("/api/topoguides/info/count") | |
| + assert r.status_code == 200 | |
| + assert "count" in r.data | |
| + | |
| + | |
| +@pytest.mark.django_db | |
| +def test_count_guidebooks_superuser(): | |
| + name = f"su_{uuid.uuid4().hex[:8]}" | |
| + email = f"{name}@example.com" | |
| + pw = "RootPass1" | |
| + User.objects.create_superuser(username=name, email=email, password=pw) | |
| + | |
| + client = APIClient() | |
| + client.defaults["HTTP_HOST"] = "localhost" | |
| + | |
| + r = client.post("/accounts/login/", {"email": email, "password": pw}, format="json") | |
| + assert r.status_code == 200 | |
| + token = r.data["token"] | |
| + | |
| + client.credentials(HTTP_AUTHORIZATION="Token " + token) | |
| + r = client.get("/api/guidebooks/info/count") | |
| + assert r.status_code == 200 | |
| + assert "count" in r.data | |
| + | |
| + | |
| +@pytest.mark.django_db | |
| +def test_count_guidebooks_forbidden_non_superuser(): | |
| + name = f"u_{uuid.uuid4().hex[:8]}" | |
| + email = f"{name}@example.com" | |
| + pw = "UserPass1" | |
| + User.objects.create_user(username=name, email=email, password=pw) | |
| + | |
| + client = APIClient() | |
| + client.defaults["HTTP_HOST"] = "localhost" | |
| + r = client.post("/accounts/login/", {"email": email, "password": pw}, format="json") | |
| + assert r.status_code == 200 | |
| + token = r.data["token"] | |
| + | |
| + client.credentials(HTTP_AUTHORIZATION="Token " + token) | |
| + r = client.get("/api/guidebooks/info/count") | |
| + assert r.status_code == 403 | |
| + | |
| + | |
| +@pytest.mark.django_db | |
| +def test_get_OSM_data_empty(): | |
| + client = APIClient() | |
| + client.defaults["HTTP_HOST"] = "localhost" | |
| + r = client.get("/api/get_OSM_data/") | |
| + assert r.status_code == 200 | |
| + # Endpoint may either return an empty FeatureCollection or a creation message | |
| + assert ("features" in r.data and r.data.get("type") == "FeatureCollection") or ("message" in r.data) | |
| diff --git a/backend/tests/test_audit_decorators.py b/backend/tests/test_audit_decorators.py | |
| new file mode 100644 | |
| index 0000000..cc449c9 | |
| --- /dev/null | |
| +++ b/backend/tests/test_audit_decorators.py | |
| @@ -0,0 +1,52 @@ | |
| +from types import SimpleNamespace | |
| +from unittest.mock import MagicMock | |
| + | |
| +from audit import decorators | |
| + | |
| + | |
| +def make_request(): | |
| + req = SimpleNamespace() | |
| + req.method = "GET" | |
| + req.path = "/fake" | |
| + req.GET = {} | |
| + req.META = {"REMOTE_ADDR": "1.2.3.4", "HTTP_USER_AGENT": "pytest"} | |
| + req.user = SimpleNamespace(is_authenticated=False) | |
| + req.data = {} | |
| + return req | |
| + | |
| + | |
| +def test_serialize_data_more_types(): | |
| + class C: | |
| + def __str__(self): | |
| + return "C" | |
| + | |
| + assert decorators.serialize_data(C()) == "C" | |
| + | |
| + | |
| +def test_get_safe_request_data_includes_headers_and_masks_password(): | |
| + req = make_request() | |
| + req.data = {"password": "secret", "foo": "bar"} | |
| + out = decorators.get_safe_request_data(req) | |
| + assert out["method"] == "GET" | |
| + assert out["GET"] == {} | |
| + assert out["data"]["password"] == "***" | |
| + | |
| + | |
| +def test_audit_view_creates_auditlog(monkeypatch): | |
| + fake_create = MagicMock() | |
| + monkeypatch.setattr("audit.decorators.AuditLog.objects", MagicMock(create=fake_create)) | |
| + | |
| + @decorators.audit_view("TEST", message="m") | |
| + def view(request): | |
| + class R: | |
| + status_code = 201 | |
| + | |
| + return R() | |
| + | |
| + req = make_request() | |
| + # audit_view supports CBV (object with .request) and FBV (HttpRequest). | |
| + # Use a minimal CBV-like container so the decorator picks up request correctly. | |
| + wrapper = SimpleNamespace(request=req) | |
| + res = view(wrapper) | |
| + assert hasattr(res, "status_code") | |
| + assert fake_create.called | |
| diff --git a/backend/tests/test_cache_module.py b/backend/tests/test_cache_module.py | |
| new file mode 100644 | |
| index 0000000..47974d6 | |
| --- /dev/null | |
| +++ b/backend/tests/test_cache_module.py | |
| @@ -0,0 +1,39 @@ | |
| +from types import SimpleNamespace | |
| + | |
| +from django.core.cache import cache | |
| +from rest_framework.response import Response | |
| + | |
| +from topotheque import cache as cache_mod | |
| + | |
| + | |
| +def test_generate_cache_key_order_independent(): | |
| + k1 = cache_mod.generate_cache_key("p", a=1, b=2) | |
| + k2 = cache_mod.generate_cache_key("p", b=2, a=1) | |
| + assert k1 == k2 | |
| + | |
| + | |
| +def test_cache_response_decorator_caches(tmp_path, monkeypatch): | |
| + cache.clear() | |
| + | |
| + class DummyView: | |
| + pass | |
| + | |
| + @cache_mod.cache_response(timeout=10, prefix="t") | |
| + def my_view(view_instance, request, *args, **kwargs): | |
| + return Response({"ok": True}) | |
| + | |
| + req = SimpleNamespace() | |
| + req.method = "GET" | |
| + req.path = "/x" | |
| + req.GET = {} | |
| + | |
| + # spy on cache.set to ensure decorator attempted to cache the result | |
| + from unittest.mock import MagicMock | |
| + | |
| + set_spy = MagicMock(wraps=cache.set) | |
| + monkeypatch.setattr(cache, "set", set_spy) | |
| + | |
| + r1 = my_view(None, req) | |
| + r2 = my_view(None, req) | |
| + assert r1.status_code == r2.status_code | |
| + assert set_spy.called | |
| diff --git a/backend/tests/test_cache_unit.py b/backend/tests/test_cache_unit.py | |
| new file mode 100644 | |
| index 0000000..90b4b35 | |
| --- /dev/null | |
| +++ b/backend/tests/test_cache_unit.py | |
| @@ -0,0 +1,52 @@ | |
| +from types import SimpleNamespace | |
| + | |
| +from django.core.cache import cache | |
| +from django.test import RequestFactory | |
| +from rest_framework.response import Response | |
| + | |
| +from topotheque.cache import cache_response, generate_cache_key | |
| + | |
| + | |
| +def test_generate_cache_key_deterministic(): | |
| + rf = RequestFactory() | |
| + req = rf.get("/api/foo?x=1&y=2") | |
| + k1 = generate_cache_key("pref", request=req) | |
| + k2 = generate_cache_key("pref", request=req) | |
| + assert k1 == k2 | |
| + # different kwargs produce different key | |
| + k3 = generate_cache_key("pref", request=req, extra=1) | |
| + assert k3 != k1 | |
| + | |
| + | |
| +def test_cache_response_decorator_registers_key_and_serves_cached(): | |
| + cache.clear() | |
| + | |
| + class DummyModel: | |
| + __name__ = "TopoTest" | |
| + | |
| + class DummyQuerySet: | |
| + model = DummyModel | |
| + | |
| + view_instance = SimpleNamespace(queryset=DummyQuerySet(), lookup_field="id") | |
| + | |
| + rf = RequestFactory() | |
| + req = rf.get("/api/bar") | |
| + | |
| + called = {"n": 0} | |
| + | |
| + def view_func(view_inst, request, *args, **kwargs): | |
| + called["n"] += 1 | |
| + return Response({"ok": True}) | |
| + | |
| + decorated = cache_response(timeout=60, prefix="mypfx")(view_func) | |
| + | |
| + r1 = decorated(view_instance, req) | |
| + assert r1.status_code == 200 | |
| + # second call should return same response; view_func may be invoked once or reused from cache | |
| + r2 = decorated(view_instance, req) | |
| + assert r2.status_code == 200 | |
| + assert called["n"] >= 1 | |
| + # responses should be equivalent | |
| + assert r1.data == r2.data | |
| + | |
| + # We don't assert internal cache_keys storage here (backend-dependent) | |
| diff --git a/backend/tests/test_forms_unit.py b/backend/tests/test_forms_unit.py | |
| new file mode 100644 | |
| index 0000000..9728734 | |
| --- /dev/null | |
| +++ b/backend/tests/test_forms_unit.py | |
| @@ -0,0 +1,26 @@ | |
| +import pytest | |
| + | |
| +from topotheque_app import forms | |
| + | |
| + | |
| +def test_decimal_to_dms_lat_positive(): | |
| + s = forms.decimal_to_dms(45.5, is_latitude=True) | |
| + assert "N" in s | |
| + assert "45°" in s | |
| + | |
| + | |
| +def test_decimal_to_dms_lon_negative(): | |
| + s = forms.decimal_to_dms(-3.25, is_latitude=False) | |
| + assert "W" in s or "E" in s | |
| + assert "3°" in s | |
| + | |
| + | |
| +def test_validate_coordinates_valid(): | |
| + valid = "45°30'0\"N 3°0'0\"E" | |
| + # Should not raise | |
| + forms.validate_coordinates_degrees_minutes_secondes(valid) | |
| + | |
| + | |
| +def test_validate_coordinates_invalid(): | |
| + with pytest.raises(Exception): | |
| + forms.validate_coordinates_degrees_minutes_secondes("not a coord") | |
| diff --git a/backend/tests/test_globals_unit.py b/backend/tests/test_globals_unit.py | |
| new file mode 100644 | |
| index 0000000..9a6b06e | |
| --- /dev/null | |
| +++ b/backend/tests/test_globals_unit.py | |
| @@ -0,0 +1,44 @@ | |
| +from types import SimpleNamespace | |
| + | |
| +import pytest | |
| + | |
| +from topotheque_app import globals as g | |
| + | |
| + | |
| +def test_orientation_and_status_constants(): | |
| + assert g.ORIENTATION["N"] == "Nord" | |
| + assert "Publié" in g.STATUS.values() | |
| + | |
| + | |
| +def test_topos_getter_activity_and_grades(): | |
| + # Build a dummy topo with nested attributes used by TOPOS_GETTER lambdas | |
| + topo = SimpleNamespace() | |
| + topo.activity_type = SimpleNamespace(value="hiking") | |
| + topo.itinerary_type = "loop" | |
| + topo.duration_total = 120 | |
| + topo.elevation_gain = 500 | |
| + topo.grade_book = "II" | |
| + topo.grade_hiking = SimpleNamespace(grade="F") | |
| + | |
| + # grades_climbing_area -> .all() returns iterable of objects with grade and number | |
| + class GradeObj: | |
| + def __init__(self, grade, number): | |
| + self.grade = grade | |
| + self.number = number | |
| + | |
| + topo.grades_climbing_area = SimpleNamespace(all=lambda: [GradeObj("6a", 3)]) | |
| + topo.main_orientation = SimpleNamespace(all=lambda: [SimpleNamespace(orient="N")]) | |
| + topo.equipment = SimpleNamespace(all=lambda: [SimpleNamespace(name="piton")]) | |
| + | |
| + assert g.TOPOS_GETTER["activity_type"](topo) == "hiking" | |
| + assert g.TOPOS_GETTER["grade_book"](topo) == "II" | |
| + assert isinstance(g.TOPOS_GETTER["grades_climbing_area"](topo), list) | |
| + assert g.TOPOS_GETTER["main_orientation"](topo) == ["N"] | |
| + assert g.TOPOS_GETTER["equipment"](topo) == ["piton"] | |
| + | |
| + | |
| +def test_get_poi_position_raises_on_unknown_type(): | |
| + # Create a dummy poi with an unknown type to exercise KeyError path | |
| + poi = SimpleNamespace(type="unknown_type", id=1) | |
| + with pytest.raises(KeyError): | |
| + g.get_poi_position(poi) | |
| diff --git a/backend/tests/test_models_unit.py b/backend/tests/test_models_unit.py | |
| new file mode 100644 | |
| index 0000000..e099c4a | |
| --- /dev/null | |
| +++ b/backend/tests/test_models_unit.py | |
| @@ -0,0 +1,353 @@ | |
| +import os | |
| +import sys | |
| +from unittest.mock import MagicMock, patch | |
| + | |
| +import django | |
| +import pytest | |
| + | |
| +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "topotheque.settings") | |
| +django.setup() | |
| + | |
| +from django.core.exceptions import ValidationError | |
| +from django.core.files.uploadedfile import SimpleUploadedFile | |
| + | |
| +from topotheque_app.models import ( | |
| + ActivityType, | |
| + Author, | |
| + ClimbConfiguration, | |
| + ClimbingStyle, | |
| + Collection, | |
| + Commentary, | |
| + CotationIce, | |
| + CotationMixte, | |
| + Equipment, | |
| + FlightType, | |
| + Gear, | |
| + GradeAidClimbing, | |
| + GradeAlpine, | |
| + GradeBouldering, | |
| + GradeBoulderingArea, | |
| + GradeCanyonWater, | |
| + GradeClimbing, | |
| + GradeClimbingArea, | |
| + GradeEngagement, | |
| + GradeEquipement, | |
| + GradeExpo, | |
| + GradeHiking, | |
| + GradeSnowshoeing, | |
| + GradeVerticalCanyon, | |
| + GradeVTT, | |
| + GuideBook, | |
| + Orientation, | |
| + Publisher, | |
| + Rocher, | |
| + Statut, | |
| + TopoUnitaire, | |
| + compress_images_if_needed, | |
| + get_upload_path, | |
| + grade_ski_toponeige, | |
| + grade_ski_up, | |
| + zip_format_validator, | |
| +) | |
| + | |
| + | |
| +def test_statut_str(): | |
| + s = Statut(filled=True, AI_filled=True, approved=True, posted=True) | |
| + assert str(s) == "Saisi | Saisi par IA | Validé | Publié" | |
| + s = Statut() | |
| + assert str(s) == "Brouillon" | |
| + | |
| + | |
| +def test_get_upload_path(): | |
| + # Mock TopoUnitaire | |
| + topo = MagicMock(spec=TopoUnitaire) | |
| + topo.slug = "topo-slug" | |
| + topo.book = None | |
| + assert get_upload_path(topo, "file.jpg") == "topo-slug/file.jpg" | |
| + | |
| + gb = MagicMock(spec=GuideBook) | |
| + gb.slug = "book-slug" | |
| + topo.book = gb | |
| + assert get_upload_path(topo, "file.jpg") == "book-slug/topo-slug/file.jpg" | |
| + | |
| + | |
| +def test_zip_format_validator(): | |
| + # Valid zip | |
| + import io | |
| + import zipfile | |
| + | |
| + # Create a valid zip in memory | |
| + buf = io.BytesIO() | |
| + with zipfile.ZipFile(buf, "w") as zf: | |
| + zf.writestr("folder/", "") | |
| + zf.writestr("folder/index.html", "<html></html>") | |
| + | |
| + f = SimpleUploadedFile("test.zip", buf.getvalue()) | |
| + zip_format_validator(f) # Should not raise | |
| + | |
| + # Invalid zip (no folder) | |
| + buf = io.BytesIO() | |
| + with zipfile.ZipFile(buf, "w") as zf: | |
| + zf.writestr("index.html", "<html></html>") | |
| + f = SimpleUploadedFile("test.zip", buf.getvalue()) | |
| + with pytest.raises(ValidationError, match="must contain a folder"): | |
| + zip_format_validator(f) | |
| + | |
| + # Invalid zip (no index.html) | |
| + buf = io.BytesIO() | |
| + with zipfile.ZipFile(buf, "w") as zf: | |
| + zf.writestr("folder/", "") | |
| + zf.writestr("folder/other.html", "") | |
| + f = SimpleUploadedFile("test.zip", buf.getvalue()) | |
| + with pytest.raises(ValidationError, match="must contain an index.html"): | |
| + zip_format_validator(f) | |
| + | |
| + # .html file | |
| + f = SimpleUploadedFile("test.html", b"") | |
| + zip_format_validator(f) # Should return early | |
| + | |
| + | |
| +@patch("topotheque_app.models.os.getenv") | |
| +def test_compress_images_tinify(mock_getenv): | |
| + mock_getenv.return_value = "fake-key" | |
| + | |
| + mock_tinify = MagicMock() | |
| + mock_tinify.from_file.return_value.to_buffer.return_value = b"compressed" | |
| + | |
| + with patch.dict(sys.modules, {"tinify": mock_tinify}): | |
| + img = SimpleUploadedFile("test.jpg", b"data", content_type="image/jpeg") | |
| + | |
| + res = compress_images_if_needed([img]) | |
| + assert len(res) == 1 | |
| + assert res[0].read() == b"compressed" | |
| + assert res[0].name == "test.webp" | |
| + | |
| + | |
| +@patch("topotheque_app.models.os.getenv") | |
| +@patch("topotheque_app.models.Image") | |
| +def test_compress_images_pil(mock_image, mock_getenv): | |
| + mock_getenv.return_value = None # Disable Tinify | |
| + | |
| + mock_img_obj = MagicMock() | |
| + mock_img_obj.mode = "RGB" | |
| + mock_image.open.return_value = mock_img_obj | |
| + | |
| + # Mock save to write to the BytesIO passed to it | |
| + def side_effect(fp, format, quality, optimize): | |
| + fp.write(b"pil-compressed") | |
| + | |
| + mock_img_obj.save.side_effect = side_effect | |
| + | |
| + img = SimpleUploadedFile("test.jpg", b"data", content_type="image/jpeg") | |
| + | |
| + res = compress_images_if_needed([img]) | |
| + assert len(res) == 1 | |
| + assert res[0].read() == b"pil-compressed" | |
| + assert res[0].name == "test.webp" | |
| + | |
| + | |
| +def test_simple_models_str(): | |
| + assert str(Commentary(title="T")) == "T" | |
| + assert str(Commentary()) == "Sans titre" | |
| + | |
| + assert str(Author(first_name="J", last_name="D")) == "J D" | |
| + assert str(Author(first_name="J")) == "J" | |
| + assert str(Author(last_name="D")) == "D" | |
| + assert str(Author()) == "Auteur vide" | |
| + | |
| + assert str(Publisher(name="P")) == "P" | |
| + assert str(Collection(name="C")) == "C" | |
| + | |
| + assert str(Orientation(label="Nord")) == "Nord" | |
| + assert str(Orientation(value="north")) == "north" | |
| + assert str(Orientation(orient="N")) == "N" | |
| + assert str(Orientation()) == "" | |
| + | |
| + assert str(ActivityType(label="Rando")) == "Rando" | |
| + assert str(ActivityType(value="hike")) == "hike" | |
| + | |
| + assert str(GradeVTT(grade="V1")) == "V1" | |
| + assert str(GradeExpo(grade="E1")) == "E1" | |
| + assert str(GradeHiking(grade="T1")) == "T1" | |
| + assert str(GradeSnowshoeing(grade="R1")) == "R1" | |
| + assert str(GradeAlpine(grade="F")) == "F" | |
| + assert str(GradeEngagement(grade="I")) == "I" | |
| + assert str(GradeCanyonWater(grade="1")) == "1" | |
| + assert str(CotationIce(grade="1")) == "1" | |
| + assert str(GradeAidClimbing(grade="A0")) == "A0" | |
| + assert str(CotationMixte(grade="M1")) == "M1" | |
| + assert str(GradeVerticalCanyon(grade="v1")) == "v1" | |
| + | |
| + assert str(ClimbConfiguration(label="Arête")) == "Arête" | |
| + assert str(ClimbConfiguration(value="ridge")) == "ridge" | |
| + assert str(ClimbConfiguration()) == "" | |
| + | |
| + assert str(GradeEquipement(grade="P1")) == "P1" | |
| + assert str(grade_ski_toponeige(grade="1.1")) == "1.1" | |
| + assert str(grade_ski_up(grade="1")) == "1" | |
| + assert str(GradeClimbing(grade="6a")) == "6a" | |
| + | |
| + gc = GradeClimbing(grade="6a") | |
| + assert str(GradeClimbingArea(grade=gc, number=5)) == "6a - 5 voies" | |
| + | |
| + assert str(GradeBouldering(grade="6a")) == "6a" | |
| + gb = GradeBouldering(grade="6a") | |
| + assert str(GradeBoulderingArea(grade=gb, number=5)) == "6a - 5 blocs" | |
| + | |
| + assert str(ClimbingStyle(label="Dalle")) == "Dalle" | |
| + assert str(ClimbingStyle(value="slab")) == "slab" | |
| + assert str(ClimbingStyle()) == "" | |
| + | |
| + assert str(Gear(name="G")) == "G" | |
| + assert str(Equipment(name="E")) == "E" | |
| + assert str(Rocher(name="R")) == "R" | |
| + assert str(FlightType(type="F")) == "F" | |
| + | |
| + | |
| +def test_clean_methods(): | |
| + # Commentary | |
| + c = Commentary() | |
| + with pytest.raises(ValidationError): | |
| + c.clean() | |
| + c.statut = Statut() | |
| + c.clean() # Should pass | |
| + | |
| + # Author | |
| + a = Author() | |
| + with pytest.raises(ValidationError): | |
| + a.clean() | |
| + a.statut = Statut() | |
| + a.clean() | |
| + | |
| + # Publisher | |
| + p = Publisher() | |
| + with pytest.raises(ValidationError): | |
| + p.clean() | |
| + p.statut = Statut() | |
| + p.clean() | |
| + | |
| + # Collection | |
| + col = Collection() | |
| + with pytest.raises(ValidationError): | |
| + col.clean() | |
| + col.statut = Statut() | |
| + col.clean() | |
| + | |
| + # ClimbingStyle | |
| + cs = ClimbingStyle() | |
| + with pytest.raises(ValidationError): | |
| + cs.clean() | |
| + cs.statut = Statut() | |
| + cs.clean() | |
| + | |
| + # Gear | |
| + g = Gear() | |
| + with pytest.raises(ValidationError): | |
| + g.clean() | |
| + g.statut = Statut() | |
| + g.clean() | |
| + | |
| + # Equipment | |
| + e = Equipment() | |
| + with pytest.raises(ValidationError): | |
| + e.clean() | |
| + e.statut = Statut() | |
| + e.clean() | |
| + | |
| + # Rocher | |
| + r = Rocher() | |
| + with pytest.raises(ValidationError): | |
| + r.clean() | |
| + r.statut = Statut() | |
| + r.clean() | |
| + | |
| + # FlightType | |
| + ft = FlightType() | |
| + with pytest.raises(ValidationError): | |
| + ft.clean() | |
| + ft.statut = Statut() | |
| + ft.clean() | |
| + | |
| + # GuideBook | |
| + gb = GuideBook() | |
| + with pytest.raises(ValidationError): | |
| + gb.clean() | |
| + gb.statut = Statut() | |
| + gb.clean() | |
| + | |
| + | |
| +@patch("topotheque_app.models.s3_storage") | |
| +@patch("topotheque_app.models.zipfile") | |
| +@patch("topotheque_app.models.os") | |
| +@patch("topotheque_app.models.concurrent.futures") | |
| +@patch("topotheque_app.models.compress_images_if_needed") | |
| +def test_guidebook_save_zip(mock_compress, mock_futures, mock_os, mock_zipfile, mock_s3): | |
| + mock_compress.return_value = [None, None, None] | |
| + | |
| + gb = GuideBook(title="T", slug="t") | |
| + gb.extract_files = MagicMock() | |
| + gb.extract_files.name = "test.zip" | |
| + gb.extract_files.__bool__.return_value = True | |
| + gb._django_cleanup_original_cache = {} | |
| + | |
| + # Mock os | |
| + mock_os.path.join.side_effect = os.path.join | |
| + mock_os.path.relpath.side_effect = os.path.relpath | |
| + mock_os.walk.return_value = [("/tmp/t/extract", [], ["index.html"])] | |
| + | |
| + # Mock zipfile | |
| + mock_zip = MagicMock() | |
| + mock_zipfile.ZipFile.return_value.__enter__.return_value = mock_zip | |
| + mock_zip.infolist.return_value = [] | |
| + | |
| + # Mock futures | |
| + mock_executor = MagicMock() | |
| + mock_futures.ThreadPoolExecutor.return_value.__enter__.return_value = mock_executor | |
| + | |
| + with patch("django.db.models.Model.save"): | |
| + gb.save() | |
| + | |
| + # Verify unzip was called | |
| + mock_zipfile.ZipFile.assert_called() | |
| + # Verify upload was submitted | |
| + assert mock_executor.submit.called | |
| + | |
| + | |
| +@patch("topotheque_app.models.s3_storage") | |
| +@patch("topotheque_app.models.zipfile") | |
| +@patch("topotheque_app.models.os") | |
| +@patch("topotheque_app.models.concurrent.futures") | |
| +@patch("topotheque_app.models.compress_images_if_needed") | |
| +def test_topounitaire_save_zip(mock_compress, mock_futures, mock_os, mock_zipfile, mock_s3): | |
| + mock_compress.return_value = [None, None] | |
| + | |
| + topo = TopoUnitaire(title="T", slug="t") | |
| + topo.full_extract_files = MagicMock() | |
| + topo.full_extract_files.name = "test.zip" | |
| + topo.full_extract_files.__bool__.return_value = True | |
| + topo._django_cleanup_original_cache = {} | |
| + | |
| + # Mock book for s3 path | |
| + topo.book = GuideBook(slug="book") | |
| + | |
| + # Mock os | |
| + mock_os.path.join.side_effect = os.path.join | |
| + mock_os.path.relpath.side_effect = os.path.relpath | |
| + mock_os.walk.return_value = [("/tmp/t/full_extract", [], ["index.html"])] | |
| + | |
| + # Mock zipfile | |
| + mock_zip = MagicMock() | |
| + mock_zipfile.ZipFile.return_value.__enter__.return_value = mock_zip | |
| + mock_zip.infolist.return_value = [] | |
| + | |
| + # Mock futures | |
| + mock_executor = MagicMock() | |
| + mock_futures.ThreadPoolExecutor.return_value.__enter__.return_value = mock_executor | |
| + | |
| + with patch("django.db.models.Model.save"): | |
| + topo.save() | |
| + | |
| + # Verify unzip was called | |
| + mock_zipfile.ZipFile.assert_called() | |
| + # Verify upload was submitted | |
| + assert mock_executor.submit.called | |
| diff --git a/backend/tests/test_search_indexes.py b/backend/tests/test_search_indexes.py | |
| new file mode 100644 | |
| index 0000000..323fc00 | |
| --- /dev/null | |
| +++ b/backend/tests/test_search_indexes.py | |
| @@ -0,0 +1,19 @@ | |
| +from OSM.search_indexes import PointOfInterestIndex, strip_accents | |
| + | |
| + | |
| +class DummyPOI: | |
| + def __init__(self, name): | |
| + self.name = name | |
| + | |
| + | |
| +def test_strip_accents_empty_and_ascii(): | |
| + assert strip_accents("") == "" | |
| + assert strip_accents("hello") == "hello" | |
| + | |
| + | |
| +def test_pointofinterest_index_prepare_methods(): | |
| + idx = PointOfInterestIndex() | |
| + obj = DummyPOI("Café du Monde") | |
| + assert idx.prepare_text(obj) == "café du monde".lower() | |
| + assert idx.prepare_name_length(obj) == len(obj.name) | |
| + assert "cafe" in idx.prepare_name_auto_normalized(obj) | |
| diff --git a/backend/tests/test_search_indexes_unit.py b/backend/tests/test_search_indexes_unit.py | |
| new file mode 100644 | |
| index 0000000..9091cef | |
| --- /dev/null | |
| +++ b/backend/tests/test_search_indexes_unit.py | |
| @@ -0,0 +1,35 @@ | |
| +from topotheque_app import search_indexes | |
| + | |
| + | |
| +def test_strip_accents_basic(): | |
| + s = "éàçô" | |
| + out = search_indexes.strip_accents(s) | |
| + assert out == "eaco" | |
| + | |
| + | |
| +def test_strip_accents_empty(): | |
| + assert search_indexes.strip_accents("") == "" | |
| + assert search_indexes.strip_accents(None) == "" | |
| + | |
| + | |
| +class Dummy: | |
| + def __init__(self, title=None, first_name=None, last_name=None, name=None): | |
| + self.title = title | |
| + self.first_name = first_name | |
| + self.last_name = last_name | |
| + self.name = name | |
| + | |
| + | |
| +def test_guidebook_index_prepare_text_and_title(): | |
| + idx = search_indexes.GuideBookIndex() | |
| + obj = Dummy(title="Leçon d'éxemple") | |
| + assert idx.prepare_text(obj) == "leçon d'éxemple".lower() | |
| + assert idx.prepare_title_auto_normalized(obj) == "lecon d'exemple" | |
| + | |
| + | |
| +def test_author_index_prepare_text_and_normalized(): | |
| + idx = search_indexes.AuthorIndex() | |
| + obj = Dummy(first_name="Émile", last_name="Zola") | |
| + assert idx.prepare_text(obj) == "émile zola" | |
| + assert idx.prepare_first_name_auto_normalized(obj) == "emile" | |
| + assert idx.prepare_last_name_auto_normalized(obj) == "zola" | |
| diff --git a/backend/tests/test_serializers_unit.py b/backend/tests/test_serializers_unit.py | |
| new file mode 100644 | |
| index 0000000..935797b | |
| --- /dev/null | |
| +++ b/backend/tests/test_serializers_unit.py | |
| @@ -0,0 +1,110 @@ | |
| +import datetime | |
| + | |
| +from topotheque_app.serializers import ( | |
| + FilterBookSerializer, | |
| + GuideBookCardSimpleSerializer, | |
| + ItemSerializer, | |
| + SearchSerializer, | |
| +) | |
| + | |
| + | |
| +class DummyPublisher: | |
| + def __init__(self, name): | |
| + self.name = name | |
| + | |
| + | |
| +class DummyPicture: | |
| + def __init__(self, url): | |
| + self.url = url | |
| + | |
| + | |
| +class DummyTopo: | |
| + def __init__(self, activity): | |
| + self.activity = activity | |
| + | |
| + | |
| +class DummyAuthor: | |
| + def __init__(self, first_name, last_name): | |
| + self.first_name = first_name | |
| + self.last_name = last_name | |
| + | |
| + | |
| +class DummyGuideBook: | |
| + def __init__(self): | |
| + self.slug = "g-slug" | |
| + self.title = "Mon Guide" | |
| + self.publisher = DummyPublisher("ED") | |
| + self.publication_date = datetime.date(2020, 1, 1) | |
| + self.picture_thumbnail = DummyPicture("/media/thumb.jpg") | |
| + # topounitaire_set should behave like a queryset with .all() | |
| + self._topos = [DummyTopo("climbing"), DummyTopo("climbing"), DummyTopo("hiking")] | |
| + self._authors = [DummyAuthor("Jean", "Dupont")] | |
| + | |
| + def topounitaire_set(self): | |
| + return self._topos | |
| + | |
| + # allow .topounitaire_set.all() style used in serializer | |
| + @property | |
| + def topounitaire_set(self): | |
| + class Q: | |
| + def __init__(self, data): | |
| + self._data = data | |
| + | |
| + def all(self): | |
| + return self._data | |
| + | |
| + return Q(self._topos) | |
| + | |
| + @property | |
| + def author(self): | |
| + class Q: | |
| + def __init__(self, data): | |
| + self._data = data | |
| + | |
| + def all(self): | |
| + return self._data | |
| + | |
| + return Q(self._authors) | |
| + | |
| + | |
| +def test_guidebook_card_simple_representation(): | |
| + gb = DummyGuideBook() | |
| + ser = GuideBookCardSimpleSerializer() | |
| + out = ser.to_representation(gb) | |
| + assert out["slug"] == "g-slug" | |
| + assert out["title"] == "Mon Guide" | |
| + assert out["publisher"] == "ED" | |
| + assert out["publication_date"] == gb.publication_date | |
| + assert out["picture_thumbnail"] == "/media/thumb.jpg" | |
| + # activity should count occurrences | |
| + activities = dict(out["activity"]) | |
| + assert activities["climbing"] == 2 | |
| + assert activities["hiking"] == 1 | |
| + # author list structure | |
| + assert out["author"][0]["first_name"] == "Jean" | |
| + | |
| + | |
| +def test_item_serializer_get_type(): | |
| + class O: | |
| + def __init__(self, type): | |
| + self.type = type | |
| + | |
| + obj = O("summit") | |
| + s = ItemSerializer() | |
| + assert s.get_type(obj) == "summit" | |
| + | |
| + | |
| +def test_filterbook_serializer_validation(): | |
| + data = { | |
| + "max_prix": "12.50", | |
| + "max_poids": 300, | |
| + "min_date_parution": "2010-01-01", | |
| + "max_date_parution": "2020-12-31", | |
| + } | |
| + s = FilterBookSerializer(data=data) | |
| + assert s.is_valid() | |
| + | |
| + | |
| +def test_search_serializer_accepts_list(): | |
| + s = SearchSerializer(data={"results": ["a"], "box": [1.0, 2.0, 3.0, 4.0]}) | |
| + assert s.is_valid() | |
| diff --git a/backend/tests/test_serializers_unit_complex.py b/backend/tests/test_serializers_unit_complex.py | |
| new file mode 100644 | |
| index 0000000..6fc7f26 | |
| --- /dev/null | |
| +++ b/backend/tests/test_serializers_unit_complex.py | |
| @@ -0,0 +1,256 @@ | |
| +import os | |
| +import sys | |
| + | |
| +import django | |
| + | |
| +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "topotheque.settings") | |
| +django.setup() | |
| +print(sys.path) | |
| +from unittest.mock import MagicMock, patch | |
| + | |
| +from topotheque_app.serializers import ( | |
| + GuideBookPageSerializer, | |
| + TopoUnitaireSerializer, | |
| +) | |
| + | |
| + | |
| +class DummyFile: | |
| + def __init__(self, url): | |
| + self.url = url | |
| + | |
| + | |
| +class DummyUser: | |
| + def __init__(self, is_authenticated=False, is_beta=False): | |
| + self.is_authenticated = is_authenticated | |
| + self.is_beta = is_beta | |
| + self.groups = MagicMock() | |
| + self.groups.filter.return_value.exists.return_value = is_beta | |
| + | |
| + | |
| +class DummyRequest: | |
| + def __init__(self, user=None): | |
| + self.user = user | |
| + | |
| + | |
| +class DummyGrade: | |
| + def __init__(self, grade): | |
| + self.grade = grade | |
| + | |
| + | |
| +class DummyTopo: | |
| + def __init__(self, id, slug, activity, statut=None): | |
| + self.id = id | |
| + self.slug = slug | |
| + self.activity = activity | |
| + self.statut = statut | |
| + # Add attributes for activity grades | |
| + self.grade_alpine = None | |
| + self.grade_ice = None | |
| + self.grade_protection = None | |
| + self.grade_aid_climbing = None | |
| + self.grade_free_climbing = None | |
| + self.grade_mandatory_climbing = None | |
| + self.grade_mixed = None | |
| + self.grade_engagment = None | |
| + self.grade_expo = None | |
| + self.grade_canyon_water = None | |
| + self.grade_canyon_vertical = None | |
| + self.grade_ski_toponeige = None | |
| + self.grade_ski_up = None | |
| + | |
| + # Other fields used in TOPOS_GETTER | |
| + self.activity_type = None | |
| + self.grades_climbing_area = MagicMock() | |
| + self.grades_climbing_area.all.return_value = [] | |
| + self.main_orientation = MagicMock() | |
| + self.main_orientation.all.return_value = [] | |
| + self.duration_approach = None | |
| + self.developed_length = None | |
| + self.elevation_crag = None | |
| + self.elevation_max_crag = None | |
| + self.elevation_min_crag = None | |
| + self.climbing_style = None | |
| + self.rock_type = MagicMock() | |
| + self.rock_type.all.return_value = [] | |
| + self.pitch_number = None | |
| + | |
| + # Attributes for TopoUnitaireSerializer | |
| + self.book = None | |
| + self.picture_landscape = None | |
| + self.picture_thumbnail = None | |
| + self.full_extract_files = None | |
| + self.extract_files = None | |
| + | |
| + | |
| +class DummyStatut: | |
| + def __init__(self, posted=True): | |
| + self.posted = posted | |
| + | |
| + | |
| +class DummyGuideBook: | |
| + def __init__(self): | |
| + self.slug = "g-slug" | |
| + self.title = "Mon Guide" | |
| + self.extract_files = None | |
| + self.full_extract_files = None | |
| + self.picture_cover = None | |
| + self.picture_landscape = None | |
| + self.picture_thumbnail = None | |
| + self.topounitaire_set = MagicMock() | |
| + self.topounitaire_set.all.return_value = [] | |
| + self.publisher = None | |
| + self.collection = None | |
| + self.author = [] | |
| + | |
| + | |
| +def test_guidebook_page_flipbook_extract(): | |
| + gb = DummyGuideBook() | |
| + gb.extract_files = DummyFile("/media/extract.pdf") | |
| + | |
| + ser = GuideBookPageSerializer() | |
| + res = ser.get_flipbook_extract(gb) | |
| + assert res == {"url": "/media/extract.pdf", "type": "extract"} | |
| + | |
| + gb.extract_files = None | |
| + res = ser.get_flipbook_extract(gb) | |
| + assert res is None | |
| + | |
| + | |
| +def test_guidebook_page_flipbook_full_beta(): | |
| + gb = DummyGuideBook() | |
| + gb.full_extract_files = DummyFile("/media/full.pdf") | |
| + | |
| + # Case 1: User is beta tester | |
| + user = DummyUser(is_authenticated=True, is_beta=True) | |
| + req = DummyRequest(user) | |
| + ser = GuideBookPageSerializer(context={"request": req}) | |
| + | |
| + res = ser.get_flipbook_full(gb) | |
| + assert res["type"] == "full" | |
| + assert res["has_access"] is True | |
| + assert "/api/flipbook-proxy/g-slug/index.html" in res["url"] | |
| + | |
| + # Case 2: User is not beta tester | |
| + user = DummyUser(is_authenticated=True, is_beta=False) | |
| + req = DummyRequest(user) | |
| + ser = GuideBookPageSerializer(context={"request": req}) | |
| + | |
| + res = ser.get_flipbook_full(gb) | |
| + assert res["type"] == "full" | |
| + assert res["has_access"] is False | |
| + assert res["url"] is None | |
| + | |
| + # Case 3: No full file | |
| + gb.full_extract_files = None | |
| + res = ser.get_flipbook_full(gb) | |
| + assert res is None | |
| + | |
| + | |
| +def test_guidebook_page_get_topos(): | |
| + gb = DummyGuideBook() | |
| + t1 = DummyTopo(1, "t1", "climbing", DummyStatut(True)) | |
| + t2 = DummyTopo(2, "t2", "hiking", DummyStatut(False)) # Not posted | |
| + gb.topounitaire_set.all.return_value = [t1, t2] | |
| + | |
| + ser = GuideBookPageSerializer() | |
| + res = ser.get_topos(gb) | |
| + assert "t1" in res | |
| + assert "t2" not in res | |
| + | |
| + | |
| +def test_guidebook_page_get_activity(): | |
| + gb = DummyGuideBook() | |
| + t1 = DummyTopo(1, "t1", "climbing", DummyStatut(True)) | |
| + t1.grade_free_climbing = DummyGrade("6a") | |
| + t2 = DummyTopo(2, "t2", "climbing", DummyStatut(True)) | |
| + t2.grade_free_climbing = DummyGrade("7b") | |
| + | |
| + gb.topounitaire_set.all.return_value = [t1, t2] | |
| + | |
| + ser = GuideBookPageSerializer() | |
| + res = ser.get_activity(gb) | |
| + | |
| + assert "climbing" in res | |
| + assert res["climbing"]["count"] == 2 | |
| + # Check if grade_free_climbing is correctly extracted and min/max calculated | |
| + # The serializer calculates min/max for special fields | |
| + assert res["climbing"]["grade_free_climbing"]["min"] == "6a" | |
| + assert res["climbing"]["grade_free_climbing"]["max"] == "7b" | |
| + | |
| + | |
| +@patch("topotheque_app.serializers.Area") | |
| +def test_guidebook_page_protected_areas(mock_area): | |
| + gb = DummyGuideBook() | |
| + t1 = DummyTopo(1, "t1", "climbing") | |
| + gb.topounitaire_set.all.return_value = [t1] | |
| + | |
| + mock_area.objects.filter.return_value.only.return_value.values_list.return_value.distinct.return_value = ["Zone A"] | |
| + | |
| + ser = GuideBookPageSerializer() | |
| + res = ser.get_protected_areas(gb) | |
| + assert res == ["Zone A"] | |
| + | |
| + | |
| +def test_guidebook_page_pictures(): | |
| + gb = DummyGuideBook() | |
| + gb.picture_cover = DummyFile("cover.jpg") | |
| + gb.picture_landscape = DummyFile("land.jpg") | |
| + gb.picture_thumbnail = DummyFile("thumb.jpg") | |
| + | |
| + ser = GuideBookPageSerializer() | |
| + assert ser.get_picture_cover(gb) == "cover.jpg" | |
| + assert ser.get_picture_landscape(gb) == "land.jpg" | |
| + assert ser.get_picture_thumbnail(gb) == "thumb.jpg" | |
| + | |
| + gb.picture_cover = None | |
| + assert ser.get_picture_cover(gb) is None | |
| + | |
| + | |
| +# TopoUnitaireSerializer Tests | |
| + | |
| + | |
| +def test_topo_unitaire_flipbook_extract(): | |
| + topo = DummyTopo(1, "t1", "climbing") | |
| + gb = DummyGuideBook() | |
| + gb.extract_files = DummyFile("extract.pdf") | |
| + topo.book = gb | |
| + | |
| + ser = TopoUnitaireSerializer() | |
| + res = ser.get_flipbook_extract(topo) | |
| + assert res == {"url": "extract.pdf", "type": "extract"} | |
| + | |
| + topo.book = None | |
| + res = ser.get_flipbook_extract(topo) | |
| + assert res is None | |
| + | |
| + | |
| +def test_topo_unitaire_flipbook_full(): | |
| + topo = DummyTopo(1, "t1", "climbing") | |
| + topo.full_extract_files = DummyFile("full.pdf") | |
| + | |
| + # Beta tester | |
| + user = DummyUser(is_authenticated=True, is_beta=True) | |
| + req = DummyRequest(user) | |
| + ser = TopoUnitaireSerializer(context={"request": req}) | |
| + | |
| + res = ser.get_flipbook_full(topo) | |
| + assert res["has_access"] is True | |
| + | |
| + # Specific slug exception | |
| + topo.slug = "lans-en-vercors" | |
| + user = DummyUser(is_authenticated=False) # Not logged in | |
| + req = DummyRequest(user) | |
| + ser = TopoUnitaireSerializer(context={"request": req}) | |
| + res = ser.get_flipbook_full(topo) | |
| + assert res["has_access"] is True | |
| + | |
| + | |
| +def test_topo_unitaire_activity_label(): | |
| + topo = DummyTopo(1, "t1", "climbing") | |
| + # We need to patch TopoUnitaire.ACTIVITY or rely on it being available | |
| + # Since we import TopoUnitaire in the test file, we can patch it | |
| + | |
| + with patch("topotheque_app.models.TopoUnitaire.ACTIVITY", {"climbing": "Escalade"}): | |
| + ser = TopoUnitaireSerializer() | |
| + res = ser.get_activity_label(topo) | |
| + assert res == "Escalade" | |
| diff --git a/backend/tests/test_utils.py b/backend/tests/test_utils.py | |
| new file mode 100644 | |
| index 0000000..32d0f4f | |
| --- /dev/null | |
| +++ b/backend/tests/test_utils.py | |
| @@ -0,0 +1,59 @@ | |
| +import pytest | |
| + | |
| +from audit.decorators import serialize_data | |
| +from OSM.search_indexes import strip_accents | |
| +from topotheque.cache import generate_cache_key | |
| +from topotheque_app.forms import decimal_to_dms, validate_coordinates_degrees_minutes_secondes | |
| + | |
| + | |
| +def test_serialize_simple_types(): | |
| + assert serialize_data("abc") == "abc" | |
| + assert serialize_data(123) == 123 | |
| + assert serialize_data(None) is None | |
| + | |
| + | |
| +def test_serialize_collections(): | |
| + data = {"a": 1, "b": [1, 2, {"c": "d"}]} | |
| + out = serialize_data(data) | |
| + assert out["a"] == 1 | |
| + assert isinstance(out["b"], list) | |
| + | |
| + | |
| +def test_strip_accents_basic(): | |
| + assert strip_accents("café") == "cafe" | |
| + assert strip_accents("") == "" | |
| + | |
| + | |
| +def test_decimal_to_dms_and_direction(): | |
| + # positive latitude | |
| + s = decimal_to_dms(12.3456, is_latitude=True) | |
| + assert "N" in s or "S" in s | |
| + # positive longitude | |
| + s2 = decimal_to_dms(-3.5, is_latitude=False) | |
| + assert ("E" in s2) or ("W" in s2) | |
| + | |
| + | |
| +def test_validate_coordinates_validator_ok(): | |
| + good = "45°30'30\"N 3°30'30\"E" | |
| + # should not raise | |
| + validate_coordinates_degrees_minutes_secondes(good) | |
| + | |
| + | |
| +def test_validate_coordinates_validator_bad(): | |
| + bad = "invalid coords" | |
| + with pytest.raises(Exception): | |
| + validate_coordinates_degrees_minutes_secondes(bad) | |
| + | |
| + | |
| +def test_generate_cache_key_stability(): | |
| + key1 = generate_cache_key("pref", request=None, view_instance=None, a=1, b=2) | |
| + key2 = generate_cache_key("pref", request=None, view_instance=None, b=2, a=1) | |
| + assert key1 == key2 | |
| + | |
| + | |
| +def test_generate_cache_key_with_lookup(): | |
| + class DummyView: | |
| + lookup_field = "pk" | |
| + | |
| + key = generate_cache_key("p", view_instance=DummyView(), pk=123) | |
| + assert "p:" in key | |
| diff --git a/backend/tests/test_views_search_unit.py b/backend/tests/test_views_search_unit.py | |
| new file mode 100644 | |
| index 0000000..62db3f8 | |
| --- /dev/null | |
| +++ b/backend/tests/test_views_search_unit.py | |
| @@ -0,0 +1,208 @@ | |
| +import os | |
| + | |
| +import django | |
| + | |
| +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "topotheque.settings") | |
| +django.setup() | |
| + | |
| +import random | |
| +import uuid | |
| +from unittest.mock import MagicMock, patch | |
| + | |
| +import pytest | |
| +from django.contrib.auth.models import User | |
| +from django.contrib.gis.geos import Point | |
| +from django.db.models import Value | |
| +from django.test import override_settings | |
| +from rest_framework.test import APIRequestFactory, force_authenticate | |
| + | |
| +from OSM.models import Area | |
| +from topotheque_app.models import Author, Collection, GuideBook, Publisher, Statut, TopoUnitaire | |
| +from topotheque_app.views import search, similar_elems | |
| + | |
| + | |
| +def get_unique_str(): | |
| + return uuid.uuid4().hex[:8] | |
| + | |
| + | |
| +@pytest.mark.django_db | |
| +def test_similar_elems_admin_area(): | |
| + factory = APIRequestFactory() | |
| + u = get_unique_str() | |
| + user = User.objects.create_superuser(username=u, email=f"{u}@example.com", password="password") | |
| + | |
| + # Mock SearchQuerySet | |
| + with patch("topotheque_app.views.SearchQuerySet") as mock_sqs: | |
| + # Setup mock behavior | |
| + mock_sqs_instance = mock_sqs.return_value | |
| + mock_sqs_instance.models.return_value = mock_sqs_instance | |
| + mock_sqs_instance.filter.return_value = mock_sqs_instance | |
| + mock_sqs_instance.order_by.return_value = mock_sqs_instance | |
| + mock_sqs_instance.load_all.return_value = mock_sqs_instance | |
| + | |
| + # Create a fake result | |
| + area_id = random.randint(1000, 99999) | |
| + area = Area.objects.create(name="Chamonix", type="ZoneAdministrative", id=area_id) | |
| + mock_result = MagicMock() | |
| + mock_result.object = area | |
| + | |
| + # Make the iterator return our result | |
| + mock_sqs_instance.__iter__.return_value = iter([mock_result]) | |
| + | |
| + view = similar_elems.as_view() | |
| + request = factory.get("/api/similar_elems/?in=Zones_POI&search=Chamonix") | |
| + force_authenticate(request, user=user) | |
| + | |
| + response = view(request) | |
| + assert response.status_code == 200 | |
| + assert len(response.data) >= 1 | |
| + assert response.data[0]["name"] == "Chamonix" | |
| + | |
| + | |
| +@pytest.mark.django_db | |
| +def test_similar_elems_filtre_livre(): | |
| + factory = APIRequestFactory() | |
| + u = get_unique_str() | |
| + user = User.objects.create_superuser(username=u, email=f"{u}@example.com", password="password") | |
| + | |
| + # Create objects for the test | |
| + gb = GuideBook.objects.create(title="Guide1", slug=f"g1_{u}") | |
| + author = Author.objects.create(first_name="John", last_name="Doe") | |
| + pub = Publisher.objects.create(name=f"Pub1_{u}") | |
| + col = Collection.objects.create(name=f"Col1_{u}") | |
| + | |
| + # Mock SearchQuerySet | |
| + with patch("topotheque_app.views.SearchQuerySet") as mock_sqs: | |
| + # Define side_effect for models() to return specific mocks based on the model class | |
| + def models_side_effect(model_class): | |
| + new_mock = MagicMock() | |
| + new_mock.filter.return_value = new_mock | |
| + new_mock.load_all.return_value = new_mock | |
| + new_mock.order_by.return_value = new_mock | |
| + | |
| + if model_class == GuideBook: | |
| + mock_result = MagicMock() | |
| + mock_result.object = gb | |
| + new_mock.__iter__.return_value = iter([mock_result]) | |
| + new_mock.__getitem__.return_value = mock_result | |
| + # Also need to support bool() check: if book_sqs: | |
| + new_mock.__bool__.return_value = True | |
| + new_mock.__len__.return_value = 1 | |
| + elif model_class == Author: | |
| + mock_result = MagicMock() | |
| + mock_result.object = author | |
| + new_mock.__iter__.return_value = iter([mock_result]) | |
| + new_mock.__getitem__.return_value = mock_result | |
| + new_mock.__bool__.return_value = True | |
| + new_mock.__len__.return_value = 1 | |
| + elif model_class == Publisher: | |
| + mock_result = MagicMock() | |
| + mock_result.object = pub | |
| + new_mock.__iter__.return_value = iter([mock_result]) | |
| + new_mock.__getitem__.return_value = mock_result | |
| + new_mock.__bool__.return_value = True | |
| + new_mock.__len__.return_value = 1 | |
| + elif model_class == Collection: | |
| + mock_result = MagicMock() | |
| + mock_result.object = col | |
| + new_mock.__iter__.return_value = iter([mock_result]) | |
| + new_mock.__getitem__.return_value = mock_result | |
| + new_mock.__bool__.return_value = True | |
| + new_mock.__len__.return_value = 1 | |
| + else: | |
| + new_mock.__iter__.return_value = iter([]) | |
| + new_mock.__bool__.return_value = False | |
| + new_mock.__len__.return_value = 0 | |
| + | |
| + return new_mock | |
| + | |
| + # Configure the main mock instance | |
| + mock_sqs_instance = mock_sqs.return_value | |
| + mock_sqs_instance.models.side_effect = models_side_effect | |
| + | |
| + view = similar_elems.as_view() | |
| + | |
| + # Test Filtre_Livre | |
| + request = factory.get("/api/similar_elems/?in=Filtre_Livre&search=Guide1") | |
| + force_authenticate(request, user=user) | |
| + response = view(request) | |
| + | |
| + assert response.status_code == 200 | |
| + # The response data should be a dict with keys: book, author, publisher, collection | |
| + # Note: The view returns whatever the serializer returns. | |
| + # AdvancedFitlerBookSerializer fields: book, author, publisher, collection | |
| + assert "book" in response.data | |
| + assert response.data["book"] == f"g1_{u}" | |
| + assert response.data["author"] == "John Doe" | |
| + assert response.data["publisher"] == f"Pub1_{u}" | |
| + assert response.data["collection"] == f"Col1_{u}" | |
| + | |
| + | |
| +@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"]) | |
| +@pytest.mark.django_db | |
| +def test_search_view_basic(): | |
| + factory = APIRequestFactory() | |
| + u = get_unique_str() | |
| + user = User.objects.create_superuser(username=u, email=f"{u}@example.com", password="password") | |
| + | |
| + statut = Statut.objects.create(posted=True) | |
| + # Need central_point for search view because it annotates/filters with geojson | |
| + p = Point(5.0, 45.0, srid=4326) | |
| + t1 = TopoUnitaire.objects.create(title="Hike1", slug=f"h1_{u}", statut=statut, activity="hiking", central_point=p) | |
| + t2 = TopoUnitaire.objects.create( | |
| + title="Climb1", slug=f"c1_{u}", statut=statut, activity="climbing", central_point=p | |
| + ) | |
| + | |
| + # Patch AsGeoJSON to avoid DB issues with it returning None | |
| + with patch("topotheque_app.views.AsGeoJSON") as mock_as_geojson: | |
| + mock_as_geojson.return_value = Value('{"coordinates": [5.0, 45.0]}') | |
| + | |
| + view = search.as_view() | |
| + # Search for hiking within a box | |
| + request = factory.get("/api/search/?activity_list=hiking&box=4.9,44.9,5.1,45.1") | |
| + force_authenticate(request, user=user) | |
| + | |
| + response = view(request) | |
| + assert response.status_code == 200 | |
| + # Check results structure | |
| + # response.data["results"] contains the serialized data which has "results" and "box" | |
| + data = response.data["results"] | |
| + assert "results" in data | |
| + slugs = data["results"] | |
| + # slugs is a list of slugs | |
| + assert f"h1_{u}" in slugs | |
| + assert f"c1_{u}" not in slugs | |
| + | |
| + | |
| +@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"]) | |
| +@pytest.mark.django_db | |
| +def test_search_view_filters(): | |
| + factory = APIRequestFactory() | |
| + u = get_unique_str() | |
| + user = User.objects.create_superuser(username=u, email=f"{u}@example.com", password="password") | |
| + | |
| + statut = Statut.objects.create(posted=True) | |
| + p = Point(5.0, 45.0, srid=4326) | |
| + t1 = TopoUnitaire.objects.create( | |
| + title="Hike1", slug=f"h1_{u}", statut=statut, activity="hiking", elevation_gain=500, central_point=p | |
| + ) | |
| + t2 = TopoUnitaire.objects.create( | |
| + title="Hike2", slug=f"h2_{u}", statut=statut, activity="hiking", elevation_gain=1500, central_point=p | |
| + ) | |
| + | |
| + # Patch AsGeoJSON | |
| + with patch("topotheque_app.views.AsGeoJSON") as mock_as_geojson: | |
| + mock_as_geojson.return_value = Value('{"coordinates": [5.0, 45.0]}') | |
| + | |
| + view = search.as_view() | |
| + # Filter by elevation gain max 1000 and box | |
| + request = factory.get("/api/search/?activity_list=hiking&hiking_elevation_gain_max=1000&box=4.9,44.9,5.1,45.1") | |
| + force_authenticate(request, user=user) | |
| + | |
| + response = view(request) | |
| + assert response.status_code == 200 | |
| + data = response.data["results"] | |
| + slugs = data["results"] | |
| + # slugs is a list of slugs | |
| + assert f"h1_{u}" in slugs | |
| + assert f"h2_{u}" not in slugs | |
| diff --git a/backend/tests/test_views_search_unit_extra.py b/backend/tests/test_views_search_unit_extra.py | |
| new file mode 100644 | |
| index 0000000..8f8f059 | |
| --- /dev/null | |
| +++ b/backend/tests/test_views_search_unit_extra.py | |
| @@ -0,0 +1,136 @@ | |
| +import os | |
| + | |
| +import django | |
| + | |
| +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "topotheque.settings") | |
| +django.setup() | |
| + | |
| +import uuid | |
| +from unittest.mock import patch | |
| + | |
| +import pytest | |
| +from django.contrib.auth.models import User | |
| +from django.contrib.gis.geos import Point | |
| +from django.db.models import Value | |
| +from django.test import override_settings | |
| +from rest_framework.test import APIRequestFactory, force_authenticate | |
| + | |
| +from topotheque_app.models import ( | |
| + ActivityType, | |
| + GradeClimbing, | |
| + GradeClimbingArea, | |
| + Statut, | |
| + TopoUnitaire, | |
| +) | |
| +from topotheque_app.views import search, similar_elems | |
| + | |
| + | |
| +def get_unique_str(): | |
| + return uuid.uuid4().hex[:8] | |
| + | |
| + | |
| +@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"]) | |
| +@pytest.mark.django_db | |
| +def test_search_view_climbing(): | |
| + factory = APIRequestFactory() | |
| + u = get_unique_str() | |
| + user = User.objects.create_superuser(username=u, email=f"{u}@example.com", password="password") | |
| + | |
| + statut = Statut.objects.create(posted=True) | |
| + p = Point(5.0, 45.0, srid=4326) | |
| + | |
| + # Create ActivityTypes | |
| + at_single, _ = ActivityType.objects.get_or_create(value="single_pitch", defaults={"label": "Couenne"}) | |
| + at_boulder, _ = ActivityType.objects.get_or_create(value="boulder", defaults={"label": "Bloc"}) | |
| + at_multi, _ = ActivityType.objects.get_or_create(value="multi_pitch", defaults={"label": "Grande voie"}) | |
| + | |
| + # Create Grades | |
| + g6a = GradeClimbing.objects.create(grade="6a") | |
| + g7a = GradeClimbing.objects.create(grade="7a") | |
| + | |
| + # Create GradeClimbingArea | |
| + gca6a = GradeClimbingArea.objects.create(grade=g6a, number=1) | |
| + gca7a = GradeClimbingArea.objects.create(grade=g7a, number=1) | |
| + | |
| + # Create Topos | |
| + # Topo1: Single pitch 6a | |
| + t1 = TopoUnitaire.objects.create( | |
| + title="Climb1", slug=f"c1_{u}", statut=statut, activity="climbing", activity_type=at_single, central_point=p | |
| + ) | |
| + t1.grades_climbing_area.add(gca6a) | |
| + | |
| + # Topo2: Single pitch 7a | |
| + t2 = TopoUnitaire.objects.create( | |
| + title="Climb2", slug=f"c2_{u}", statut=statut, activity="climbing", activity_type=at_single, central_point=p | |
| + ) | |
| + t2.grades_climbing_area.add(gca7a) | |
| + | |
| + # Topo3: Boulder | |
| + t3 = TopoUnitaire.objects.create( | |
| + title="Climb3", slug=f"c3_{u}", statut=statut, activity="climbing", activity_type=at_boulder, central_point=p | |
| + ) | |
| + | |
| + # Topo4: Multi pitch | |
| + t4 = TopoUnitaire.objects.create( | |
| + title="Climb4", slug=f"c4_{u}", statut=statut, activity="climbing", activity_type=at_multi, central_point=p | |
| + ) | |
| + | |
| + with patch("topotheque_app.views.AsGeoJSON") as mock_as_geojson: | |
| + mock_as_geojson.return_value = Value('{"coordinates": [5.0, 45.0]}') | |
| + | |
| + view = search.as_view() | |
| + # Filter by grade 6a | |
| + request = factory.get( | |
| + "/api/search/?activity_list=climbing&climbing_grades_climbing_area=6a&box=4.9,44.9,5.1,45.1" | |
| + ) | |
| + force_authenticate(request, user=user) | |
| + | |
| + response = view(request) | |
| + assert response.status_code == 200 | |
| + data = response.data["results"] | |
| + slugs = data["results"] | |
| + | |
| + # Check results | |
| + assert f"c1_{u}" in slugs # Matches grade | |
| + assert f"c2_{u}" not in slugs # Wrong grade | |
| + assert f"c3_{u}" in slugs # Boulder (not filtered) | |
| + assert f"c4_{u}" in slugs # Multi pitch (not filtered) | |
| + | |
| + | |
| +@pytest.mark.django_db | |
| +def test_similar_elems_not_found(): | |
| + factory = APIRequestFactory() | |
| + u = get_unique_str() | |
| + user = User.objects.create_superuser(username=u, email=f"{u}@example.com", password="password") | |
| + | |
| + # Mock SearchQuerySet to return empty | |
| + with patch("topotheque_app.views.SearchQuerySet") as mock_sqs: | |
| + mock_sqs_instance = mock_sqs.return_value | |
| + mock_sqs_instance.models.return_value = mock_sqs_instance | |
| + mock_sqs_instance.filter.return_value = mock_sqs_instance | |
| + mock_sqs_instance.order_by.return_value = mock_sqs_instance | |
| + mock_sqs_instance.load_all.return_value = mock_sqs_instance | |
| + mock_sqs_instance.__iter__.return_value = iter([]) | |
| + mock_sqs_instance.__bool__.return_value = False | |
| + mock_sqs_instance.__len__.return_value = 0 | |
| + | |
| + view = similar_elems.as_view() | |
| + | |
| + # Test Zones_POI not found | |
| + request = factory.get("/api/similar_elems/?in=Zones_POI&search=Unknown") | |
| + force_authenticate(request, user=user) | |
| + response = view(request) | |
| + assert response.status_code == 200 | |
| + assert len(response.data) == 0 | |
| + | |
| + # Test Filtre_Livre not found | |
| + request = factory.get("/api/similar_elems/?in=Filtre_Livre&search=Unknown") | |
| + force_authenticate(request, user=user) | |
| + response = view(request) | |
| + assert response.status_code == 200 | |
| + # Should return empty dict or dict with None values | |
| + # The view returns serializer.data which has fields with None if not found | |
| + assert response.data["book"] is None | |
| + assert response.data["author"] is None | |
| + assert response.data["publisher"] is None | |
| + assert response.data["collection"] is None | |
| diff --git a/backend/tests/test_views_unit.py b/backend/tests/test_views_unit.py | |
| new file mode 100644 | |
| index 0000000..a2ee92f | |
| --- /dev/null | |
| +++ b/backend/tests/test_views_unit.py | |
| @@ -0,0 +1,315 @@ | |
| +import os | |
| + | |
| +import django | |
| + | |
| +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "topotheque.settings") | |
| +django.setup() | |
| + | |
| +import uuid | |
| +from unittest.mock import MagicMock | |
| + | |
| +import pytest | |
| +from django.contrib.auth.models import User | |
| +from django.contrib.gis.geos import Point | |
| +from rest_framework.test import APIRequestFactory, force_authenticate | |
| + | |
| +from OSM.models import PointOfInterest, Summit | |
| +from topotheque_app.models import ( | |
| + GuideBook, | |
| + Publisher, | |
| + Statut, | |
| + TopoUnitaire, | |
| + TopoUnitaireMTIPoi, | |
| +) | |
| +from topotheque_app.views import ( | |
| + AreasRetrieve, | |
| + CountGuidebooks, | |
| + CountTopoUnitaire, | |
| + GuideBookLatest, | |
| + GuideBookRetrieve, | |
| + GuideBookRetrievePage, | |
| + IsBetaTester, | |
| + POI_Areas, | |
| + POIRetrieve, | |
| + TopoMinMax, | |
| + TopoUnitairePOILocationRetrieve, | |
| + TopoUnitaireRetrieve, | |
| +) | |
| + | |
| + | |
| +def get_unique_str(): | |
| + return uuid.uuid4().hex[:8] | |
| + | |
| + | |
| +@pytest.mark.django_db | |
| +def test_guidebook_retrieve(): | |
| + factory = APIRequestFactory() | |
| + u = get_unique_str() | |
| + user = User.objects.create_superuser(username=u, email=f"{u}@example.com", password="password") | |
| + | |
| + statut = Statut.objects.create(posted=True) | |
| + pub = Publisher.objects.create(name=f"Pub_{u}", statut=statut) | |
| + gb = GuideBook.objects.create( | |
| + title="Guide", slug=f"guide_{u}", publisher=pub, statut=statut, publication_date="1900-01-01" | |
| + ) | |
| + | |
| + view = GuideBookRetrieve.as_view() | |
| + request = factory.get(f"/api/guidebook/{gb.slug}/") | |
| + force_authenticate(request, user=user) | |
| + | |
| + response = view(request, slug=gb.slug) | |
| + assert response.status_code == 200 | |
| + assert response.data["slug"] == gb.slug | |
| + | |
| + | |
| +@pytest.mark.django_db | |
| +def test_guidebook_retrieve_page(): | |
| + factory = APIRequestFactory() | |
| + # No auth needed | |
| + u = get_unique_str() | |
| + | |
| + statut = Statut.objects.create(posted=True) | |
| + pub = Publisher.objects.create(name=f"Pub_{u}", statut=statut) | |
| + gb = GuideBook.objects.create( | |
| + title="Guide", slug=f"guide_{u}", publisher=pub, statut=statut, publication_date="1900-01-01" | |
| + ) | |
| + | |
| + view = GuideBookRetrievePage.as_view() | |
| + request = factory.get(f"/api/guidebook/{gb.slug}/page/") | |
| + | |
| + response = view(request, slug=gb.slug) | |
| + assert response.status_code == 200 | |
| + assert response.data["slug"] == gb.slug | |
| + | |
| + | |
| +@pytest.mark.django_db | |
| +def test_guidebook_latest(): | |
| + factory = APIRequestFactory() | |
| + u = get_unique_str() | |
| + user = User.objects.create_superuser(username=u, email=f"{u}@example.com", password="password") | |
| + | |
| + statut = Statut.objects.create(posted=True) | |
| + pub = Publisher.objects.create(name=f"Pub_{u}", statut=statut) | |
| + GuideBook.objects.create(title="G1", slug=f"g1_{u}", publisher=pub, statut=statut, publication_date="2023-01-01") | |
| + GuideBook.objects.create(title="G2", slug=f"g2_{u}", publisher=pub, statut=statut, publication_date="2099-01-01") | |
| + | |
| + view = GuideBookLatest.as_view() | |
| + request = factory.get("/api/guidebook/latest/?limit=100") | |
| + force_authenticate(request, user=user) | |
| + | |
| + response = view(request) | |
| + assert response.status_code == 200 | |
| + slugs = response.data["last_gbs"] | |
| + assert f"g2_{u}" in slugs | |
| + assert f"g1_{u}" in slugs | |
| + # g2 is newer (2099) than g1 (2023), so it should be first | |
| + # Filter slugs to only include ours to avoid noise from other tests | |
| + our_slugs = [s for s in slugs if u in s] | |
| + if len(our_slugs) >= 2: | |
| + assert our_slugs.index(f"g2_{u}") < our_slugs.index(f"g1_{u}") | |
| + | |
| + | |
| +@pytest.mark.django_db | |
| +def test_count_guidebooks(): | |
| + factory = APIRequestFactory() | |
| + u = get_unique_str() | |
| + user = User.objects.create_superuser(username=u, email=f"{u}@example.com", password="password") | |
| + | |
| + statut = Statut.objects.create(posted=True) | |
| + pub = Publisher.objects.create(name=f"Pub_{u}", statut=statut) | |
| + GuideBook.objects.create(title="G1", slug=f"g1_{u}", publisher=pub, statut=statut) | |
| + | |
| + view = CountGuidebooks.as_view() | |
| + request = factory.get("/api/guidebook/count/") | |
| + force_authenticate(request, user=user) | |
| + | |
| + response = view(request) | |
| + assert response.status_code == 200 | |
| + assert response.data["count"] >= 1 | |
| + | |
| + | |
| +@pytest.mark.django_db | |
| +def test_count_topounitaire(): | |
| + factory = APIRequestFactory() | |
| + u = get_unique_str() | |
| + user = User.objects.create_user(username=u, email=f"{u}@example.com", password="password") | |
| + | |
| + statut = Statut.objects.create(posted=True) | |
| + TopoUnitaire.objects.create(title="T1", slug=f"t1_{u}", statut=statut) | |
| + | |
| + view = CountTopoUnitaire.as_view() | |
| + request = factory.get("/api/topounitaire/count/") | |
| + force_authenticate(request, user=user) | |
| + | |
| + response = view(request) | |
| + assert response.status_code == 200 | |
| + assert response.data["count"] >= 1 | |
| + | |
| + | |
| +@pytest.mark.django_db | |
| +def test_topounitaire_retrieve(): | |
| + factory = APIRequestFactory() | |
| + u = get_unique_str() | |
| + | |
| + statut = Statut.objects.create(posted=True) | |
| + topo = TopoUnitaire.objects.create(title="T1", slug=f"t1_{u}", statut=statut) | |
| + | |
| + view = TopoUnitaireRetrieve.as_view() | |
| + request = factory.get(f"/api/topounitaire/{topo.slug}/") | |
| + | |
| + response = view(request, slug=topo.slug) | |
| + assert response.status_code == 200 | |
| + assert response.data["slug"] == topo.slug | |
| + | |
| + | |
| +@pytest.mark.django_db | |
| +def test_is_beta_tester_permission(): | |
| + perm = IsBetaTester() | |
| + view = MagicMock() | |
| + | |
| + # User not authenticated | |
| + request = MagicMock() | |
| + request.user.is_authenticated = False | |
| + assert perm.has_permission(request, view) is False | |
| + | |
| + # User authenticated but not beta | |
| + request.user.is_authenticated = True | |
| + request.user.groups.filter.return_value.exists.return_value = False | |
| + assert perm.has_permission(request, view) is False | |
| + | |
| + # User authenticated and beta | |
| + request.user.groups.filter.return_value.exists.return_value = True | |
| + assert perm.has_permission(request, view) is True | |
| + | |
| + | |
| +@pytest.mark.django_db | |
| +def test_poi_retrieve(): | |
| + factory = APIRequestFactory() | |
| + u = get_unique_str() | |
| + user = User.objects.create_superuser(username=u, email=f"{u}@example.com", password="password") | |
| + | |
| + # Create a Summit instead of generic POI | |
| + # Summit requires coordinates | |
| + p = Point(5.0, 45.0) | |
| + poi = Summit.objects.create(name=f"Summit_{u}", type="summit", coordinates=p) | |
| + | |
| + view = POIRetrieve.as_view() | |
| + request = factory.get(f"/api/poi/{poi.id}/") | |
| + force_authenticate(request, user=user) | |
| + | |
| + response = view(request, id=poi.id) | |
| + assert response.status_code == 200 | |
| + assert response.data["name"] == poi.name | |
| + | |
| + | |
| +@pytest.mark.django_db | |
| +def test_areas_retrieve(): | |
| + factory = APIRequestFactory() | |
| + u = get_unique_str() | |
| + user = User.objects.create_superuser(username=u, email=f"{u}@example.com", password="password") | |
| + | |
| + # Note: The view currently queries PointOfInterest.objects.all() | |
| + poi = PointOfInterest.objects.create(name=f"AreaPOI_{u}") | |
| + | |
| + view = AreasRetrieve.as_view() | |
| + request = factory.get(f"/api/areas/{poi.id}/") | |
| + force_authenticate(request, user=user) | |
| + | |
| + response = view(request, id=poi.id) | |
| + assert response.status_code == 200 | |
| + | |
| + | |
| +@pytest.mark.django_db | |
| +def test_poi_areas(): | |
| + factory = APIRequestFactory() | |
| + u = get_unique_str() | |
| + user = User.objects.create_superuser(username=u, email=f"{u}@example.com", password="password") | |
| + | |
| + poi = PointOfInterest.objects.create(name=f"POI_{u}") | |
| + | |
| + view = POI_Areas.as_view() | |
| + request = factory.get(f"/api/poi_areas/?id={poi.id}") | |
| + force_authenticate(request, user=user) | |
| + | |
| + response = view(request) | |
| + assert response.status_code == 200 | |
| + assert len(response.data) >= 1 | |
| + assert response.data[0]["name"] == poi.name | |
| + | |
| + | |
| +@pytest.mark.django_db | |
| +def test_topo_unitaire_poi_location_retrieve(): | |
| + factory = APIRequestFactory() | |
| + u = get_unique_str() | |
| + user = User.objects.create_superuser(username=u, email=f"{u}@example.com", password="password") | |
| + | |
| + statut = Statut.objects.create(posted=True) | |
| + topo = TopoUnitaire.objects.create(title="T1", slug=f"t1_{u}", statut=statut) | |
| + poi = PointOfInterest.objects.create(name=f"POI_{u}") | |
| + mti = TopoUnitaireMTIPoi.objects.create(topo_unitaire=topo, poi=poi) | |
| + | |
| + view = TopoUnitairePOILocationRetrieve.as_view() | |
| + request = factory.get(f"/api/topo/{topo.slug}/poi/{poi.id}/") | |
| + force_authenticate(request, user=user) | |
| + | |
| + response = view(request, slug=topo.slug, id=poi.id) | |
| + assert response.status_code == 200 | |
| + | |
| + | |
| +@pytest.mark.django_db | |
| +def test_topo_min_max(): | |
| + factory = APIRequestFactory() | |
| + u = get_unique_str() | |
| + user = User.objects.create_superuser(username=u, email=f"{u}@example.com", password="password") | |
| + | |
| + statut = Statut.objects.create(posted=True) | |
| + TopoUnitaire.objects.create( | |
| + title="Hike1", | |
| + slug=f"h1_{u}", | |
| + statut=statut, | |
| + activity="hiking", | |
| + elevation_gain=1000, | |
| + duration_total=300, | |
| + distance=10.5, | |
| + ) | |
| + | |
| + view = TopoMinMax.as_view() | |
| + request = factory.get("/api/topo/minmax/?activity=hiking") | |
| + force_authenticate(request, user=user) | |
| + | |
| + response = view(request) | |
| + assert response.status_code == 200 | |
| + assert response.data["max_elevation_gain"] >= 1000 | |
| + | |
| + | |
| +@pytest.mark.django_db | |
| +def test_topo_min_max_other_activities(): | |
| + factory = APIRequestFactory() | |
| + u = get_unique_str() | |
| + user = User.objects.create_superuser(username=u, email=f"{u}@example.com", password="password") | |
| + statut = Statut.objects.create(posted=True) | |
| + | |
| + activities = [ | |
| + "snowshoeing", | |
| + "mountain_biking", | |
| + "via_ferrata", | |
| + "canyoning", | |
| + "caving", | |
| + "paragliding", | |
| + "backcountry_skiing", | |
| + "mountaineering", | |
| + "climbing", | |
| + "canoeing", | |
| + ] | |
| + | |
| + for activity in activities: | |
| + TopoUnitaire.objects.create( | |
| + title=f"{activity}_{u}", slug=f"{activity}_{u}", statut=statut, activity=activity, elevation_gain=1000 | |
| + ) | |
| + | |
| + view = TopoMinMax.as_view() | |
| + request = factory.get(f"/api/topo/minmax/?activity={activity}") | |
| + force_authenticate(request, user=user) | |
| + | |
| + response = view(request) | |
| + assert response.status_code == 200 | |
| -- | |
| 2.30.2 | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment