Skip to content

Instantly share code, notes, and snippets.

@mehdibennis
Created January 18, 2026 16:55
Show Gist options
  • Select an option

  • Save mehdibennis/2057981cb373904f7692eface22625c5 to your computer and use it in GitHub Desktop.

Select an option

Save mehdibennis/2057981cb373904f7692eface22625c5 to your computer and use it in GitHub Desktop.
Patch for PR Test-implementation
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