diff --git a/mnemosyne/library/templates/library/library_list.html b/mnemosyne/library/templates/library/library_list.html index 1f28008..c4c8e46 100644 --- a/mnemosyne/library/templates/library/library_list.html +++ b/mnemosyne/library/templates/library/library_list.html @@ -15,6 +15,18 @@ +
+
+ + +
+ +
+ {% if error %}
{{ error }} @@ -53,8 +65,12 @@ {% else %} {% if not error %}
+ {% if scope == "all" %}

No libraries yet.

Create your first library to get started.

+ {% else %} +

No libraries match this filter.

+ {% endif %}
{% endif %} {% endif %} diff --git a/mnemosyne/library/tests/test_views.py b/mnemosyne/library/tests/test_views.py new file mode 100644 index 0000000..87c0858 --- /dev/null +++ b/mnemosyne/library/tests/test_views.py @@ -0,0 +1,92 @@ +"""Tests for the library CRUD HTML views. + +Currently covers ``library_list``'s Daedalus-workspace scope filter. The +view loads every ``Library`` node from Neo4j and narrows it by a ``scope`` +GET param (``all`` / ``global`` / ``daedalus``). These tests stub out +Neo4j entirely — patching ``neo4j_available`` and injecting a fake +``Library`` class via ``sys.modules`` — so they assert on the queryset +``.filter(...)`` call the view makes and the context it renders, not on +real graph behaviour. Mirrors the mocking style in +``test_search_views_admin_scope.py``. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse + +User = get_user_model() + + +class LibraryListScopeFilterTests(TestCase): + """Cover the ``scope`` filter branches of ``library_list``.""" + + def setUp(self): + self.user = User.objects.create_user( + username="op", email="op@example.com", password="pw" + ) + self.client.force_login(self.user) + self.url = reverse("library:library-list") + + def _fake_library_cls(self): + """Return (Library stub, nodes mock) where ``nodes`` chains fluently. + + ``Library.nodes`` → ``.filter(...)`` → ``.order_by(...)`` all return + the same MagicMock so the view's queryset building works regardless + of which branch it takes, and ``.filter`` records its kwargs. + """ + fake_nodes = MagicMock() + fake_nodes.filter.return_value = fake_nodes + fake_nodes.order_by.return_value = [] + return SimpleNamespace(nodes=fake_nodes), fake_nodes + + def _get(self, fake_library_cls, **params): + with patch("library.views.neo4j_available", return_value=True), \ + patch.dict( + "sys.modules", + {"library.models": SimpleNamespace(Library=fake_library_cls)}, + ): + return self.client.get(self.url, params) + + def test_default_scope_is_all_and_does_not_filter(self): + fake_cls, fake_nodes = self._fake_library_cls() + response = self._get(fake_cls) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context["scope"], "all") + fake_nodes.filter.assert_not_called() + fake_nodes.order_by.assert_called_once_with("name") + + def test_global_scope_filters_workspace_isnull_true(self): + fake_cls, fake_nodes = self._fake_library_cls() + response = self._get(fake_cls, scope="global") + + self.assertEqual(response.context["scope"], "global") + fake_nodes.filter.assert_called_once_with(workspace_id__isnull=True) + + def test_daedalus_scope_filters_workspace_isnull_false(self): + fake_cls, fake_nodes = self._fake_library_cls() + response = self._get(fake_cls, scope="daedalus") + + self.assertEqual(response.context["scope"], "daedalus") + fake_nodes.filter.assert_called_once_with(workspace_id__isnull=False) + + def test_unknown_scope_does_not_filter(self): + """An unexpected scope value degrades to the unfiltered list.""" + fake_cls, fake_nodes = self._fake_library_cls() + response = self._get(fake_cls, scope="bogus") + + self.assertEqual(response.context["scope"], "bogus") + fake_nodes.filter.assert_not_called() + + def test_neo4j_unavailable_sets_error_and_empty_list(self): + with patch("library.views.neo4j_available", return_value=False): + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(list(response.context["libraries"]), []) + self.assertEqual(response.context["error"], "Neo4j is not available.") diff --git a/mnemosyne/library/views.py b/mnemosyne/library/views.py index a48032c..87a30d2 100644 --- a/mnemosyne/library/views.py +++ b/mnemosyne/library/views.py @@ -31,14 +31,20 @@ logger = logging.getLogger(__name__) @login_required def library_list(request): - """List all libraries.""" + """List libraries, optionally filtered by Daedalus-workspace scope.""" + scope = request.GET.get("scope", "all") libraries = [] error = None if neo4j_available(): try: from .models import Library - libraries = Library.nodes.order_by("name") + qs = Library.nodes + if scope == "daedalus": + qs = qs.filter(workspace_id__isnull=False) + elif scope == "global": + qs = qs.filter(workspace_id__isnull=True) + libraries = qs.order_by("name") except Exception as e: error = f"Could not connect to Neo4j: {e}" logger.error(error) @@ -47,7 +53,7 @@ def library_list(request): return render( request, "library/library_list.html", - {"libraries": libraries, "error": error}, + {"libraries": libraries, "error": error, "scope": scope}, )