Files
mnemosyne/mnemosyne/library/tests/test_managed_by.py
Robert Helewka f1639dbdec 🐾 fix(api): cascade plain library delete; case-insensitive name conflict
DELETE /library/api/libraries/{uid}/ called bare lib.delete(), orphaning
Collections/Items/Chunks/Images and skipping Concept GC — the only delete
path not using the shared cascade. It now delegates to
delete_library_cascade like the HTML and workspace delete paths.

Name-conflict checks on both create endpoints are now case-insensitive
via find_library_by_name_ci (parameterised Cypher toLower comparison —
not neomodel iexact, which embeds the value in a regex and breaks on
names like "C++ Notes"). The Neo4j unique index is case-sensitive, so
"amazon connect" previously coexisted silently with "Amazon Connect",
confusing every name-matching client (Spelunker matches names
case-insensitively). The 409 reports the existing spelling. The plain
create also gains a UniqueProperty catch for the pre-check/save race,
mirroring the workspace path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:41:55 -04:00

208 lines
7.3 KiB
Python

"""Tests for the per-app ``managed_by`` concept.
Covers the pure helpers (``infer_legacy_manager``,
``Library.managed_by_display``), the token-derived stamping and
duplicate-name rejection on the plain create endpoint (Neo4j stubbed
via ``sys.modules``, same style as ``test_views.py``), and the
``WorkspaceStatusSerializer`` surface. Cypher-touching paths are
covered by the manual end-to-end plan, not these unit tests.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework.test import APIClient
from library.api.serializers import WorkspaceStatusSerializer
from library.models import Library, infer_legacy_manager
from mcp_server.models import UserToken
User = get_user_model()
class InferLegacyManagerTests(TestCase):
"""Truth table for the pre-``managed_by`` inference."""
def test_null_workspace_is_unmanaged(self):
self.assertIsNone(infer_legacy_manager(None))
self.assertIsNone(infer_legacy_manager(""))
def test_kairos_mail_prefix_is_kairos(self):
self.assertEqual(
infer_legacy_manager("kairos-mail-abc123-7"), "Kairos"
)
def test_other_workspace_is_daedalus(self):
self.assertEqual(
infer_legacy_manager("2f9c4a1e-uuid-ish"), "Daedalus"
)
class ManagedByDisplayTests(TestCase):
"""``managed_by_display`` on in-memory (unsaved) Library nodes."""
def test_stamped_label_wins(self):
lib = Library(name="x", managed_by="Spelunker")
self.assertEqual(lib.managed_by_display, "Spelunker")
def test_stamped_label_wins_over_inference(self):
lib = Library(
name="x", managed_by="My Token", workspace_id="kairos-mail-a-1"
)
self.assertEqual(lib.managed_by_display, "My Token")
def test_legacy_workspace_falls_back_to_inference(self):
lib = Library(name="x", workspace_id="ws-uuid")
self.assertEqual(lib.managed_by_display, "Daedalus")
def test_unmanaged_is_empty_string(self):
lib = Library(name="x")
self.assertEqual(lib.managed_by_display, "")
class _FakeLibrary:
"""Stand-in for the neomodel Library on the plain create endpoint."""
class DoesNotExist(Exception):
pass
existing = None # what find_library_by_name_ci returns
instances = [] # constructor kwargs, in order
save_raises = None # exception instance save() should raise, if any
def __init__(self, **kwargs):
type(self).instances.append(kwargs)
self.__dict__.update(kwargs)
self.uid = "lib-new"
self.workspace_id = None
self.created_at = None
def save(self):
if type(self).save_raises is not None:
raise type(self).save_raises
return self
def _fake_find_ci(name):
_FakeLibrary.ci_queries.append(name)
return _FakeLibrary.existing
def _fake_models_module():
return SimpleNamespace(
Library=_FakeLibrary, find_library_by_name_ci=_fake_find_ci
)
class LibraryCreateStampingTests(TestCase):
"""POST /library/api/libraries/ stamps ``managed_by`` and rejects dupes."""
def setUp(self):
self.user = User.objects.create_user(username="op", password="pw")
self.client = APIClient()
_FakeLibrary.existing = None
_FakeLibrary.instances = []
_FakeLibrary.save_raises = None
_FakeLibrary.ci_queries = []
def _post(self, token=None, name="Docs"):
self.client.force_authenticate(user=self.user, token=token)
with patch.dict("sys.modules", {"library.models": _fake_models_module()}):
return self.client.post(
"/library/api/libraries/",
{"name": name, "library_type": "technical"},
format="json",
)
def test_token_create_stamps_token_name(self):
response = self._post(token=UserToken(name="Spelunker"))
self.assertEqual(response.status_code, 201)
self.assertEqual(_FakeLibrary.instances[0]["managed_by"], "Spelunker")
self.assertEqual(response.json()["managed_by"], "Spelunker")
def test_session_create_leaves_managed_by_null(self):
response = self._post(token=None)
self.assertEqual(response.status_code, 201)
self.assertIsNone(_FakeLibrary.instances[0]["managed_by"])
def test_duplicate_name_returns_409_name_conflict(self):
_FakeLibrary.existing = SimpleNamespace(
uid="lib-old", name="Docs", managed_by_display="Daedalus"
)
response = self._post(token=UserToken(name="Spelunker"))
self.assertEqual(response.status_code, 409)
body = response.json()
self.assertEqual(body["code"], "name_conflict")
self.assertIn("Docs", body["detail"])
self.assertEqual(body["uid"], "lib-old")
self.assertEqual(body["managed_by"], "Daedalus")
self.assertEqual(_FakeLibrary.instances, [])
def test_duplicate_check_is_case_insensitive(self):
"""A case-variant name 409s and reports the existing spelling."""
_FakeLibrary.existing = SimpleNamespace(
uid="lib-old", name="Amazon Connect", managed_by_display="Spelunker"
)
response = self._post(token=None, name="amazon connect")
self.assertEqual(response.status_code, 409)
# The lookup received the posted name (case-folding happens in
# Cypher), and the response names the existing spelling.
self.assertEqual(_FakeLibrary.ci_queries, ["amazon connect"])
self.assertIn("Amazon Connect", response.json()["detail"])
self.assertEqual(_FakeLibrary.instances, [])
def test_duplicate_of_unmanaged_reports_null_manager(self):
_FakeLibrary.existing = SimpleNamespace(
uid="lib-old", name="Docs", managed_by_display=""
)
response = self._post(token=None)
self.assertEqual(response.status_code, 409)
self.assertIsNone(response.json()["managed_by"])
def test_save_race_returns_409_not_500(self):
"""An exact-name twin landing between pre-check and save 409s."""
from neomodel.exceptions import UniqueProperty
_FakeLibrary.save_raises = UniqueProperty("name")
response = self._post(token=None)
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()["code"], "name_conflict")
class WorkspaceStatusSerializerManagedByTests(TestCase):
"""The workspace status payload carries ``managed_by`` (nullable)."""
BASE = {
"workspace_id": "ws_a",
"library_uid": "lib_1",
"name": "W",
"library_type": "technical",
"description": "",
"item_count": 0,
"chunk_count": 0,
"created_at": "2026-01-01T00:00:00Z",
}
def test_managed_by_value_round_trips(self):
s = WorkspaceStatusSerializer(data={**self.BASE, "managed_by": "Daedalus"})
self.assertTrue(s.is_valid(), s.errors)
self.assertEqual(s.validated_data["managed_by"], "Daedalus")
def test_managed_by_null_accepted(self):
s = WorkspaceStatusSerializer(data={**self.BASE, "managed_by": None})
self.assertTrue(s.is_valid(), s.errors)
def test_managed_by_absent_accepted(self):
s = WorkspaceStatusSerializer(data=self.BASE)
self.assertTrue(s.is_valid(), s.errors)