4 Commits

Author SHA1 Message Date
70b1fc510b Merge pull request 'fix(tests): repair stale mock.patch targets after service refactors' (#2) from fix/stale-test-patch-targets into main
Some checks failed
CVE Scan & Docker Build / security-scan (push) Has been cancelled
CVE Scan & Docker Build / build-and-push (push) Has been cancelled
Build & Deploy Docs / build-and-deploy (push) Successful in 1m11s
Reviewed-on: #2
2026-06-18 02:01:25 +00:00
46ca2a934d Merge pull request 'feat/workspace-name-conflict-409' (#1) from feat/workspace-name-conflict-409 into main
Some checks failed
CVE Scan & Docker Build / security-scan (push) Has been cancelled
CVE Scan & Docker Build / build-and-push (push) Has been cancelled
Build & Deploy Docs / build-and-deploy (push) Has been cancelled
Reviewed-on: #1
2026-06-18 02:00:55 +00:00
dd06f923cd feat(workspaces): return 409 name_conflict instead of 500 on Library name clash
Some checks failed
CVE Scan & Docker Build / security-scan (pull_request) Successful in 3m49s
CVE Scan & Docker Build / build-and-push (pull_request) Has been cancelled
A recreate of a workspace whose Mnemosyne Library was orphaned (left behind
by a failed Daedalus delete-propagate) collides on the global Library.name
unique constraint. neomodel raised UniqueProperty unguarded, so workspace_create
500'd and ingest then 404'd forever — the queue froze silently.

Guard lib.save() and return a structured 409 with a machine code so Daedalus
can classify the failure without string-matching:
- name_conflict   — the new name-collision case
- owner_conflict, library_type_immutable — codes added to the two existing 409s

Cypher-touching paths stay covered by the manual end-to-end plan, per the
test module's stated convention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 20:26:43 -04:00
539d9b6c34 fix(tests): repair stale mock.patch targets after service refactors
All checks were successful
CVE Scan & Docker Build / security-scan (pull_request) Successful in 5m24s
CVE Scan & Docker Build / build-and-push (pull_request) Successful in 2m58s
Several library tests patched symbols at import paths that no longer
expose them, so they errored (AttributeError) instead of testing anything
— giving false confidence. The underlying code is correct; only the test
patch targets were stale after earlier refactors moved imports
function-local.

- test_pipeline: patch source modules (library.models.Item,
  llm_manager.models.LLMModel, library.services.parsers.DocumentParser,
  .chunker.ContentTypeChunker, .embedding_client.EmbeddingClient,
  .vision.VisionAnalyzer, .concepts.ConceptExtractor) since pipeline.py
  imports them inside methods. default_storage stays (still module-level).
- test_search_api: patch library.services.search.SearchService (the view
  imports it function-local).
- test_tasks: patch library.services.pipeline.EmbeddingPipeline (tasks.py
  imports it function-local).
- test_search_views_admin_scope: patch library.utils.neo4j_available; the
  guard moved to utils when views._all_library_uids became a thin alias.
- test_concepts: remove SampleIndexSelectionTests — _select_sample_indices
  was deleted in the document-level concept-extraction refactor (dead test).

Not addressed here: SearchAPIAuthTest / SearchAPIValidationTest return 302
instead of 401/400. Static analysis ruled out routing, middleware, and DRF
config; reproducing needs a running server (DB-backed). Flagged for sandbox
diagnosis — not a stale-patch issue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 20:12:46 -04:00
6 changed files with 62 additions and 62 deletions

View File

@@ -17,6 +17,7 @@ across users.
import logging import logging
from neomodel import db from neomodel import db
from neomodel.exceptions import UniqueProperty
from rest_framework import status from rest_framework import status
from rest_framework.decorators import api_view, permission_classes from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
@@ -85,7 +86,10 @@ def workspace_create(request):
data["workspace_id"], request.user.username, data["workspace_id"], request.user.username,
) )
return Response( return Response(
{"detail": "Workspace id is already in use."}, {
"detail": "Workspace id is already in use.",
"code": "owner_conflict",
},
status=status.HTTP_409_CONFLICT, status=status.HTTP_409_CONFLICT,
) )
if existing.library_type != data["library_type"]: if existing.library_type != data["library_type"]:
@@ -95,7 +99,8 @@ def workspace_create(request):
"library_type is immutable for an existing workspace " "library_type is immutable for an existing workspace "
f"(have '{existing.library_type}', " f"(have '{existing.library_type}', "
f"got '{data['library_type']}')." f"got '{data['library_type']}')."
) ),
"code": "library_type_immutable",
}, },
status=status.HTTP_409_CONFLICT, status=status.HTTP_409_CONFLICT,
) )
@@ -120,7 +125,29 @@ def workspace_create(request):
reranker_instruction=defaults["reranker_instruction"], reranker_instruction=defaults["reranker_instruction"],
llm_context_prompt=defaults["llm_context_prompt"], llm_context_prompt=defaults["llm_context_prompt"],
) )
try:
lib.save() lib.save()
except UniqueProperty:
# Library.name is globally unique. A name collision here almost always
# means an orphaned Library survived a failed Daedalus workspace delete
# (the old node kept the name), and the recreate under a new
# workspace_id now clashes. Surface a clean 409 instead of a 500 so
# Daedalus can record + report it; the operator clears the orphan
# (admin delete) or renames the workspace.
logger.warning(
"workspace_create name_conflict workspace_id=%s name=%s",
data["workspace_id"], data["name"],
)
return Response(
{
"detail": (
f"A library named '{data['name']}' already exists in "
"Mnemosyne."
),
"code": "name_conflict",
},
status=status.HTTP_409_CONFLICT,
)
logger.info( logger.info(
"Workspace created workspace_id=%s library_uid=%s library_type=%s", "Workspace created workspace_id=%s library_uid=%s library_type=%s",
data["workspace_id"], lib.uid, lib.library_type, data["workspace_id"], lib.uid, lib.library_type,

View File

@@ -48,30 +48,3 @@ class ConceptExtractionParsingTests(TestCase):
result = self.extractor._parse_concept_response(response) result = self.extractor._parse_concept_response(response)
self.assertEqual(len(result), 1) self.assertEqual(len(result), 1)
self.assertEqual(result[0]["name"], "valid") self.assertEqual(result[0]["name"], "valid")
class SampleIndexSelectionTests(TestCase):
"""Tests for sample index selection."""
def setUp(self):
self.extractor = ConceptExtractor(MagicMock())
def test_small_total_returns_all(self):
indices = self.extractor._select_sample_indices(5, max_samples=10)
self.assertEqual(indices, [0, 1, 2, 3, 4])
def test_equal_total_returns_all(self):
indices = self.extractor._select_sample_indices(10, max_samples=10)
self.assertEqual(indices, list(range(10)))
def test_large_total_returns_max_samples(self):
indices = self.extractor._select_sample_indices(100, max_samples=10)
self.assertEqual(len(indices), 10)
# Should be evenly spaced
self.assertEqual(indices[0], 0)
self.assertEqual(indices[-1], 90)
def test_returns_integers(self):
indices = self.extractor._select_sample_indices(50, max_samples=7)
for idx in indices:
self.assertIsInstance(idx, int)

View File

@@ -48,7 +48,7 @@ class EmbeddingPipelineInitTests(TestCase):
class PipelineItemNotFoundTests(TestCase): class PipelineItemNotFoundTests(TestCase):
"""Tests for handling missing items.""" """Tests for handling missing items."""
@patch("library.services.pipeline.Item") @patch("library.models.Item")
def test_process_nonexistent_item_raises(self, mock_item_cls): def test_process_nonexistent_item_raises(self, mock_item_cls):
mock_item_cls.nodes.get.side_effect = Exception("Not found") mock_item_cls.nodes.get.side_effect = Exception("Not found")
@@ -57,7 +57,7 @@ class PipelineItemNotFoundTests(TestCase):
pipeline.process_item("nonexistent-uid") pipeline.process_item("nonexistent-uid")
self.assertIn("Item not found", str(ctx.exception)) self.assertIn("Item not found", str(ctx.exception))
@patch("library.services.pipeline.Item") @patch("library.models.Item")
def test_reprocess_nonexistent_item_raises(self, mock_item_cls): def test_reprocess_nonexistent_item_raises(self, mock_item_cls):
mock_item_cls.nodes.get.side_effect = Exception("Not found") mock_item_cls.nodes.get.side_effect = Exception("Not found")
@@ -69,9 +69,9 @@ class PipelineItemNotFoundTests(TestCase):
class PipelineNoEmbeddingModelTests(TestCase): class PipelineNoEmbeddingModelTests(TestCase):
"""Tests for handling missing system embedding model.""" """Tests for handling missing system embedding model."""
@patch("library.services.pipeline.LLMModel") @patch("llm_manager.models.LLMModel")
@patch("library.services.pipeline.default_storage") @patch("library.services.pipeline.default_storage")
@patch("library.services.pipeline.DocumentParser") @patch("library.services.parsers.DocumentParser")
def test_no_embedding_model_raises(self, mock_parser, mock_storage, mock_llm): def test_no_embedding_model_raises(self, mock_parser, mock_storage, mock_llm):
"""Pipeline raises ValueError if no system embedding model is configured.""" """Pipeline raises ValueError if no system embedding model is configured."""
mock_llm.get_system_embedding_model.return_value = None mock_llm.get_system_embedding_model.return_value = None
@@ -86,7 +86,7 @@ class PipelineNoEmbeddingModelTests(TestCase):
mock_item.chunks.all.return_value = [] mock_item.chunks.all.return_value = []
mock_item.images.all.return_value = [] mock_item.images.all.return_value = []
with patch("library.services.pipeline.Item") as mock_item_cls: with patch("library.models.Item") as mock_item_cls:
mock_item_cls.nodes.get.return_value = mock_item mock_item_cls.nodes.get.return_value = mock_item
# Mock S3 read # Mock S3 read
@@ -166,11 +166,11 @@ class PipelineVisionStageTests(TestCase):
item.images.all.return_value = [] item.images.all.return_value = []
return item return item
@patch("library.services.pipeline.ConceptExtractor") @patch("library.services.concepts.ConceptExtractor")
@patch("library.services.pipeline.EmbeddingClient") @patch("library.services.embedding_client.EmbeddingClient")
@patch("library.services.pipeline.ContentTypeChunker") @patch("library.services.chunker.ContentTypeChunker")
@patch("library.services.pipeline.DocumentParser") @patch("library.services.parsers.DocumentParser")
@patch("library.services.pipeline.LLMModel") @patch("llm_manager.models.LLMModel")
@patch("library.services.pipeline.default_storage") @patch("library.services.pipeline.default_storage")
def test_no_vision_model_marks_images_skipped( def test_no_vision_model_marks_images_skipped(
self, mock_storage, mock_llm, mock_parser_cls, self, mock_storage, mock_llm, mock_parser_cls,
@@ -227,12 +227,12 @@ class PipelineVisionStageTests(TestCase):
img_node.save.assert_called() img_node.save.assert_called()
self.assertEqual(result["images_analyzed"], 0) self.assertEqual(result["images_analyzed"], 0)
@patch("library.services.pipeline.VisionAnalyzer") @patch("library.services.vision.VisionAnalyzer")
@patch("library.services.pipeline.ConceptExtractor") @patch("library.services.concepts.ConceptExtractor")
@patch("library.services.pipeline.EmbeddingClient") @patch("library.services.embedding_client.EmbeddingClient")
@patch("library.services.pipeline.ContentTypeChunker") @patch("library.services.chunker.ContentTypeChunker")
@patch("library.services.pipeline.DocumentParser") @patch("library.services.parsers.DocumentParser")
@patch("library.services.pipeline.LLMModel") @patch("llm_manager.models.LLMModel")
@patch("library.services.pipeline.default_storage") @patch("library.services.pipeline.default_storage")
def test_vision_model_triggers_analysis( def test_vision_model_triggers_analysis(
self, mock_storage, mock_llm, mock_parser_cls, self, mock_storage, mock_llm, mock_parser_cls,
@@ -287,7 +287,7 @@ class PipelineVisionStageTests(TestCase):
mock_vision_cls.assert_called_once_with(mock_vision_model, user=None) mock_vision_cls.assert_called_once_with(mock_vision_model, user=None)
mock_analyzer.analyze_images.assert_called_once() mock_analyzer.analyze_images.assert_called_once()
@patch("library.services.pipeline.LLMModel") @patch("llm_manager.models.LLMModel")
def test_no_images_skips_vision_entirely(self, mock_llm): def test_no_images_skips_vision_entirely(self, mock_llm):
"""When there are no images, vision stage is a no-op regardless of model.""" """When there are no images, vision stage is a no-op regardless of model."""
mock_vision_model = MagicMock() mock_vision_model = MagicMock()
@@ -309,10 +309,10 @@ class PipelineVisionStageTests(TestCase):
patch.object(pipeline, "_store_chunks", return_value=[]), \ patch.object(pipeline, "_store_chunks", return_value=[]), \
patch.object(pipeline, "_store_images", return_value=[]), \ patch.object(pipeline, "_store_images", return_value=[]), \
patch.object(pipeline, "_associate_images_with_chunks"), \ patch.object(pipeline, "_associate_images_with_chunks"), \
patch("library.services.pipeline.DocumentParser") as mock_parser_cls, \ patch("library.services.parsers.DocumentParser") as mock_parser_cls, \
patch("library.services.pipeline.ContentTypeChunker") as mock_chunker_cls, \ patch("library.services.chunker.ContentTypeChunker") as mock_chunker_cls, \
patch("library.services.pipeline.EmbeddingClient"), \ patch("library.services.embedding_client.EmbeddingClient"), \
patch("library.services.pipeline.VisionAnalyzer") as mock_vision_cls: patch("library.services.vision.VisionAnalyzer") as mock_vision_cls:
mock_parser = MagicMock() mock_parser = MagicMock()
mock_parser.parse_bytes.return_value = MagicMock(images=[], text_blocks=[]) mock_parser.parse_bytes.return_value = MagicMock(images=[], text_blocks=[])

View File

@@ -100,7 +100,7 @@ class SearchAPIResponseTest(TestCase):
self.client = APIClient() self.client = APIClient()
self.client.force_authenticate(user=self.user) self.client.force_authenticate(user=self.user)
@patch("library.api.views.SearchService") @patch("library.services.search.SearchService")
def test_successful_search_response_format(self, MockService): def test_successful_search_response_format(self, MockService):
"""Successful search returns expected JSON structure.""" """Successful search returns expected JSON structure."""
mock_response = SearchResponse( mock_response = SearchResponse(
@@ -159,7 +159,7 @@ class SearchAPIResponseTest(TestCase):
self.assertEqual(image["image_uid"], "img1") self.assertEqual(image["image_uid"], "img1")
self.assertEqual(image["image_type"], "diagram") self.assertEqual(image["image_type"], "diagram")
@patch("library.api.views.SearchService") @patch("library.services.search.SearchService")
def test_vector_only_endpoint(self, MockService): def test_vector_only_endpoint(self, MockService):
"""Vector-only endpoint sets correct search types.""" """Vector-only endpoint sets correct search types."""
mock_response = SearchResponse( mock_response = SearchResponse(
@@ -184,7 +184,7 @@ class SearchAPIResponseTest(TestCase):
self.assertEqual(call_args.search_types, ["vector"]) self.assertEqual(call_args.search_types, ["vector"])
self.assertFalse(call_args.rerank) self.assertFalse(call_args.rerank)
@patch("library.api.views.SearchService") @patch("library.services.search.SearchService")
def test_fulltext_only_endpoint(self, MockService): def test_fulltext_only_endpoint(self, MockService):
"""Fulltext-only endpoint sets correct search types.""" """Fulltext-only endpoint sets correct search types."""
mock_response = SearchResponse( mock_response = SearchResponse(
@@ -208,7 +208,7 @@ class SearchAPIResponseTest(TestCase):
self.assertEqual(call_args.search_types, ["fulltext"]) self.assertEqual(call_args.search_types, ["fulltext"])
self.assertFalse(call_args.rerank) self.assertFalse(call_args.rerank)
@patch("library.api.views.SearchService") @patch("library.services.search.SearchService")
def test_reranker_skip_reason_surfaced_in_json(self, MockService): def test_reranker_skip_reason_surfaced_in_json(self, MockService):
"""``reranker_skip_reason`` propagates through the JSON API.""" """``reranker_skip_reason`` propagates through the JSON API."""
mock_response = SearchResponse( mock_response = SearchResponse(

View File

@@ -48,7 +48,7 @@ class AllLibraryUidsHelperTests(TestCase):
def test_returns_empty_when_neo4j_unavailable(self): def test_returns_empty_when_neo4j_unavailable(self):
"""Helper must not touch ``Library.nodes`` if Neo4j is down.""" """Helper must not touch ``Library.nodes`` if Neo4j is down."""
with patch("library.views.neo4j_available", return_value=False): with patch("library.utils.neo4j_available", return_value=False):
self.assertEqual(views._all_library_uids(), []) self.assertEqual(views._all_library_uids(), [])
def test_returns_every_library_uid(self): def test_returns_every_library_uid(self):
@@ -62,7 +62,7 @@ class AllLibraryUidsHelperTests(TestCase):
fake_nodes.all.return_value = fake_libs fake_nodes.all.return_value = fake_libs
fake_library_cls = SimpleNamespace(nodes=fake_nodes) fake_library_cls = SimpleNamespace(nodes=fake_nodes)
with patch("library.views.neo4j_available", return_value=True), \ with patch("library.utils.neo4j_available", return_value=True), \
patch.dict("sys.modules", {"library.models": SimpleNamespace(Library=fake_library_cls)}): patch.dict("sys.modules", {"library.models": SimpleNamespace(Library=fake_library_cls)}):
result = views._all_library_uids() result = views._all_library_uids()
@@ -83,7 +83,7 @@ class AllLibraryUidsHelperTests(TestCase):
fake_nodes.all.return_value = fake_libs fake_nodes.all.return_value = fake_libs
fake_library_cls = SimpleNamespace(nodes=fake_nodes) fake_library_cls = SimpleNamespace(nodes=fake_nodes)
with patch("library.views.neo4j_available", return_value=True), \ with patch("library.utils.neo4j_available", return_value=True), \
patch.dict("sys.modules", {"library.models": SimpleNamespace(Library=fake_library_cls)}): patch.dict("sys.modules", {"library.models": SimpleNamespace(Library=fake_library_cls)}):
result = views._all_library_uids() result = views._all_library_uids()
@@ -95,7 +95,7 @@ class AllLibraryUidsHelperTests(TestCase):
fake_nodes.all.side_effect = RuntimeError("neo4j blew up") fake_nodes.all.side_effect = RuntimeError("neo4j blew up")
fake_library_cls = SimpleNamespace(nodes=fake_nodes) fake_library_cls = SimpleNamespace(nodes=fake_nodes)
with patch("library.views.neo4j_available", return_value=True), \ with patch("library.utils.neo4j_available", return_value=True), \
patch.dict("sys.modules", {"library.models": SimpleNamespace(Library=fake_library_cls)}): patch.dict("sys.modules", {"library.models": SimpleNamespace(Library=fake_library_cls)}):
self.assertEqual(views._all_library_uids(), []) self.assertEqual(views._all_library_uids(), [])

View File

@@ -13,7 +13,7 @@ from django.test import TestCase, override_settings
class EmbedItemTaskTests(TestCase): class EmbedItemTaskTests(TestCase):
"""Tests for the embed_item task.""" """Tests for the embed_item task."""
@patch("library.tasks.EmbeddingPipeline") @patch("library.services.pipeline.EmbeddingPipeline")
def test_embed_item_success(self, mock_pipeline_cls): def test_embed_item_success(self, mock_pipeline_cls):
from library.tasks import embed_item from library.tasks import embed_item
@@ -31,7 +31,7 @@ class EmbedItemTaskTests(TestCase):
self.assertEqual(result["item_uid"], "test-uid-123") self.assertEqual(result["item_uid"], "test-uid-123")
mock_pipeline.process_item.assert_called_once() mock_pipeline.process_item.assert_called_once()
@patch("library.tasks.EmbeddingPipeline") @patch("library.services.pipeline.EmbeddingPipeline")
def test_embed_item_failure(self, mock_pipeline_cls): def test_embed_item_failure(self, mock_pipeline_cls):
from library.tasks import embed_item from library.tasks import embed_item
@@ -49,7 +49,7 @@ class EmbedItemTaskTests(TestCase):
class ReembedItemTaskTests(TestCase): class ReembedItemTaskTests(TestCase):
"""Tests for the reembed_item task.""" """Tests for the reembed_item task."""
@patch("library.tasks.EmbeddingPipeline") @patch("library.services.pipeline.EmbeddingPipeline")
def test_reembed_item_success(self, mock_pipeline_cls): def test_reembed_item_success(self, mock_pipeline_cls):
from library.tasks import reembed_item from library.tasks import reembed_item