From 5afb460386a19f8bd65b62aab9bf5f3b9eb7ed71 Mon Sep 17 00:00:00 2001 From: witbrock Date: Tue, 18 Aug 2026 13:40:13 +0200 Subject: [PATCH] Add governed legacy name cleanup --- .../ontology_publication_authority.md | 18 +- src/backend/server/routes/concept_routes.py | 62 +++ src/backend/services/concept_service.py | 13 +- .../ontology_mutation_command_service.py | 405 ++++++++++++++ .../web/von_interface/static/js/apiService.js | 10 +- .../web/von_interface/static/js/conceptTab.js | 124 +++-- .../test_legacy_inline_name_cleanup.py | 500 ++++++++++++++++++ tests/frontend/apiServiceUserContext.test.js | 28 +- .../frontend/conceptTabNameResolution.test.js | 115 +++- 9 files changed, 1223 insertions(+), 52 deletions(-) create mode 100644 tests/backend/test_legacy_inline_name_cleanup.py diff --git a/docs/engineering/ontology_publication_authority.md b/docs/engineering/ontology_publication_authority.md index bc5d0d8f..f939f2e1 100644 --- a/docs/engineering/ontology_publication_authority.md +++ b/docs/engineering/ontology_publication_authority.md @@ -7,7 +7,7 @@ - **Authority scope:** Canonical Vontology publication, retraction, identity consolidation, and publication-scope change - **Owner:** Von maintainers -- **Last reviewed:** 13 August 2026 +- **Last reviewed:** 18 August 2026 - **Review trigger:** Merge, deployment, a role-lifecycle change, or a new canonical ontology mutation entry point - **Live authority or implementation evidence:** Initial activation completed @@ -94,6 +94,16 @@ and graph-wide rename and delete execution remain fail-closed until they can carry an equivalent complete effect plan; their read or preview paths remain available where safe. +Legacy inline `names[]` cleanup is a dedicated governed effect rather than a +generic concept update. The authenticated client receives an opaque selector +bound to the concept, exact array position, exact entry value, and complete +array snapshot. Execution rechecks that selector under the same concept-level +lock used by canonical `hasName` mutations, requires a canonical `hasName` to +remain, compare-and-sets only the selected legacy entry, and succeeds only when +read-back proves both the removal and the unchanged canonical-name snapshot. +Natural-language values are not trimmed, case-folded, title-cased, or Unicode +normalised by the deletion boundary. + Governed creation defaults to the actor's exact user-private publication context. Organisation publication must be requested explicitly as `organisation_general` and requires that organisation's semantic authority. @@ -173,6 +183,12 @@ tampering, and cross-audience delegation denial; alternate entry-point enforcement; private-context non-leakage; retry/concurrency behaviour; and receipt-backed canonical read-back of successful and partial effects. +For legacy inline-name cleanup, the bounded evidence additionally covers exact +Unicode preservation, stale and repeated selector refusal, canonical-name +retention, shared locking with canonical `hasName` writes, authenticated HTTP +actor binding, embedding invalidation, and user-visible deletion through the +same browser control used by the Concept tab. + The motivating historical changes are evidence targets, not general permission to alter live Vontology. Applying any such change still requires a deployed authority configuration and a separately authorised actor/effect. diff --git a/src/backend/server/routes/concept_routes.py b/src/backend/server/routes/concept_routes.py index d1c9d778..c1c4cb1d 100644 --- a/src/backend/server/routes/concept_routes.py +++ b/src/backend/server/routes/concept_routes.py @@ -27,6 +27,7 @@ maybe_delete_text_relation_doc_from_rag, maybe_sync_concept_text_relations_to_rag, ) +from ...services.ontology_mutation_command_service import delete_legacy_name from ...services.paper_recommendation_profile_vontology_service import ( load_paper_recommendation_profile, upsert_paper_recommendation_profile, @@ -84,6 +85,13 @@ def _governed_mutation_response( "ontology_mutation_replay_projection_unavailable", }: return jsonify(result), 503 + if error_code in { + "legacy_name_snapshot_precondition_failed", + "legacy_name_selector_precondition_failed", + "legacy_name_compare_and_set_failed", + "canonical_has_name_precondition_failed", + }: + return jsonify(result), 409 return jsonify(result), 400 @@ -1757,3 +1765,57 @@ def delete_concept_text_relation_route(concept_id: str, relation_id: str): exc_info=True, ) return jsonify(error="Failed to delete text relation"), 500 + + +@concept_bp.route("//legacy-names", methods=["DELETE"]) +def delete_concept_legacy_name_route(concept_id: str): + """Remove one exact legacy inline name through semantic authority.""" + + from ...security.access_control import ( + AUTHENTICATED_SESSION_ACTOR_SOURCE, + AUTHENTICATED_SESSION_DERIVED_ACTOR_SOURCE, + get_effective_user_concept_id_with_source, + ) + + actor_id, actor_source = get_effective_user_concept_id_with_source() + if not actor_id or actor_source not in { + AUTHENTICATED_SESSION_ACTOR_SOURCE, + AUTHENTICATED_SESSION_DERIVED_ACTOR_SOURCE, + }: + return _governed_mutation_response( + { + "success": False, + "effect_status": "not_started", + "mutation_outcome": "not_started", + "changed": False, + "error_code": "authenticated_actor_context_required", + "error": "A trusted signed-in actor is required.", + } + ) + payload = request.get_json(silent=True) or {} + selector = payload.get("legacy_name_selector") + if not isinstance(selector, dict): + return ( + jsonify( + success=False, + effect_status="not_started", + mutation_outcome="not_started", + changed=False, + error_code="exact_legacy_name_selector_required", + error="legacy_name_selector must be an exact selector object.", + ), + 400, + ) + try: + result = delete_legacy_name( + concept_id=concept_id, + legacy_name_selector=selector, + request_id=payload.get("request_id"), + ) + return _governed_mutation_response(result) + except Exception: + current_app.logger.exception( + "Error deleting a legacy inline name for %s", + concept_id, + ) + return jsonify(error="Failed to delete legacy name"), 500 diff --git a/src/backend/services/concept_service.py b/src/backend/services/concept_service.py index b8bb8960..f245c418 100644 --- a/src/backend/services/concept_service.py +++ b/src/backend/services/concept_service.py @@ -1224,6 +1224,7 @@ def enrich_concept_with_text_relations( "language": item.get("lang", "en-NZ"), "type": item.get("context", {}).get("name_type", "NL"), "relation_id": item.get("relation_id"), + "storage_kind": "text_relation", } for item in names_from_relations ] @@ -1240,7 +1241,11 @@ def enrich_concept_with_text_relations( if isinstance(item, dict) } if isinstance(legacy_names, list): - for legacy_name in legacy_names: + from .ontology_mutation_command_service import ( + legacy_name_selector_metadata, + ) + + for legacy_ordinal, legacy_name in enumerate(legacy_names): name_text = ( legacy_name.get("name", "") if isinstance(legacy_name, dict) @@ -1268,6 +1273,12 @@ def enrich_concept_with_text_relations( "language": language, "type": name_type, "relation_id": None, + "storage_kind": "legacy_inline", + "legacy_name_selector": legacy_name_selector_metadata( + concept_id=concept_id, + names=legacy_names, + ordinal=legacy_ordinal, + ), } ) existing_name_keys.add(key) diff --git a/src/backend/services/ontology_mutation_command_service.py b/src/backend/services/ontology_mutation_command_service.py index 8803b399..2f6e9320 100644 --- a/src/backend/services/ontology_mutation_command_service.py +++ b/src/backend/services/ontology_mutation_command_service.py @@ -8,6 +8,7 @@ from collections.abc import Callable, Mapping, Sequence from contextlib import ExitStack from dataclasses import dataclass +from datetime import UTC, datetime from typing import Any from ..db.repositories.concepts_repository import ConceptsRepository @@ -72,6 +73,7 @@ def __str__(self) -> str: "upsert_singleton_text_relation": "text.upsert", "add_names_to_concept": "text.upsert", "delete_text_relation": "text.delete", + "delete_legacy_name": "text.delete", "add_relationship": "relationship.add", "remove_relationship": "relationship.remove", "remove_relationships_bulk": "relationship.remove", @@ -311,6 +313,162 @@ def _canonical_json_sha256(value: Any) -> str: return hashlib.sha256(encoded).hexdigest() +def _raw_json_sha256(value: Any) -> str: + """Hash a JSON-safe value without changing any Unicode string value. + + ``ensure_ascii=False`` makes the byte contract explicit: composed and + decomposed Unicode remain distinct UTF-8 byte sequences. Sorting object + keys makes hashes stable without normalising, trimming, or case-folding + any stored string. + """ + + encoded = json.dumps( + _json_safe(value), + sort_keys=True, + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def legacy_name_selector_metadata( + *, + concept_id: str, + names: Sequence[Any], + ordinal: int, +) -> dict[str, Any]: + """Return the opaque exact selector exposed for one legacy ``names[]`` row.""" + + canonical_id = _normalise_concept_id(concept_id) + if canonical_id is None: + raise ValueError("concept_id is required") + if isinstance(ordinal, bool) or not isinstance(ordinal, int): + raise TypeError("legacy name ordinal must be an integer") + snapshot = list(names) + if ordinal < 0 or ordinal >= len(snapshot): + raise IndexError("legacy name ordinal is out of range") + return { + "concept_id": canonical_id, + "ordinal": ordinal, + "entry_sha256": _raw_json_sha256(snapshot[ordinal]), + "names_snapshot_sha256": _raw_json_sha256(snapshot), + } + + +def _legacy_name_document_snapshot(concept_id: str) -> dict[str, Any]: + concept = ConceptsRepository.find_one({"concept_id": concept_id}) + if not isinstance(concept, Mapping): + raise OntologyMutationCommandError( + "ontology_mutation_target_not_found", + "The requested ontology target was not found.", + ) + raw_names = concept.get("names") + names = list(raw_names) if isinstance(raw_names, list) else [] + return { + "concept_id": concept_id, + "names": names, + "names_count": len(names), + "names_snapshot_sha256": _raw_json_sha256(names), + } + + +def _canonical_has_name_snapshot(concept_id: str) -> dict[str, Any]: + rows: list[dict[str, Any]] = [] + relations = TextRelationsRepository.find( + { + "subject_concept_id": concept_id, + "predicate": {"$in": ["hasName", "#V#hasName"]}, + } + ) + for relation in relations: + if not isinstance(relation, Mapping): + continue + text_value = TextValuesRepository.find_one_by_id(relation.get("object_text_id")) + if not isinstance(text_value, Mapping) or not isinstance( + text_value.get("text"), str + ): + continue + rows.append( + { + "relation_id": str(relation.get("_id") or ""), + "object_text_id": str(relation.get("object_text_id") or ""), + "predicate": relation.get("predicate"), + "text_sha256": hashlib.sha256( + text_value["text"].encode("utf-8") + ).hexdigest(), + "language_sha256": _raw_json_sha256(text_value.get("lang")), + "context_sha256": _raw_json_sha256(relation.get("context") or {}), + "provenance_sha256": _raw_json_sha256( + text_value.get("provenance") or {} + ), + } + ) + rows.sort(key=lambda row: (row["relation_id"], row["object_text_id"])) + return { + "canonical_has_name_present": bool(rows), + "canonical_has_name_count": len(rows), + "canonical_has_name_relation_ids": [row["relation_id"] for row in rows], + "canonical_has_name_snapshot_sha256": _raw_json_sha256(rows), + } + + +def _validated_legacy_name_precondition( + *, + concept_id: str, + legacy_name_selector: Any, +) -> dict[str, Any]: + if not isinstance(legacy_name_selector, Mapping): + raise OntologyMutationCommandError( + "exact_legacy_name_selector_required", + "An exact legacy name selector is required.", + ) + selector_concept_id = _normalise_concept_id(legacy_name_selector.get("concept_id")) + ordinal = legacy_name_selector.get("ordinal") + entry_sha256 = _clean_text(legacy_name_selector.get("entry_sha256")).lower() + names_snapshot_sha256 = _clean_text( + legacy_name_selector.get("names_snapshot_sha256") + ).lower() + + def valid_sha256(value: str) -> bool: + return len(value) == 64 and all( + character in "0123456789abcdef" for character in value + ) + + if ( + selector_concept_id != concept_id + or isinstance(ordinal, bool) + or not isinstance(ordinal, int) + or ordinal < 0 + or not valid_sha256(entry_sha256) + or not valid_sha256(names_snapshot_sha256) + ): + raise OntologyMutationCommandError( + "exact_legacy_name_selector_required", + "The legacy name selector is incomplete or does not match this concept.", + ) + + snapshot = _legacy_name_document_snapshot(concept_id) + if snapshot["names_snapshot_sha256"] != names_snapshot_sha256: + raise OntologyMutationCommandError( + "legacy_name_snapshot_precondition_failed", + "The legacy name list changed; reload the concept and select it again.", + ) + names = snapshot["names"] + if ordinal >= len(names) or _raw_json_sha256(names[ordinal]) != entry_sha256: + raise OntologyMutationCommandError( + "legacy_name_selector_precondition_failed", + "The selected legacy name is no longer present at that exact position.", + ) + resulting_names = [*names[:ordinal], *names[ordinal + 1 :]] + return { + **snapshot, + "ordinal": ordinal, + "entry_sha256": entry_sha256, + "resulting_names": resulting_names, + "resulting_names_snapshot_sha256": _raw_json_sha256(resulting_names), + } + + def _uncertain_assertion_snapshot( *, source_id: str, @@ -1060,6 +1218,47 @@ def build_ontology_mutation_intent( # authoritative; reverse traversal belongs to the derived extent. "maintain_parent_inverse": False, } + elif method == "delete_legacy_name": + subject_id = _normalise_concept_id(arguments.get("concept_id")) + if not subject_id: + raise OntologyMutationCommandError( + "ontology_mutation_subject_required", + "A canonical ontology mutation subject is required.", + ) + _visible_or_fail((subject_id,)) + legacy_snapshot = _validated_legacy_name_precondition( + concept_id=subject_id, + legacy_name_selector=arguments.get("legacy_name_selector"), + ) + canonical_names = _canonical_has_name_snapshot(subject_id) + if canonical_names["canonical_has_name_present"] is not True: + raise OntologyMutationCommandError( + "canonical_has_name_required_for_legacy_cleanup", + ( + "A legacy inline name can be removed only after a canonical " + "hasName relation exists." + ), + ) + publication_context = concept_publication_context(subject_id) + targets = (subject_id,) + predicate = "hasName" + delta = { + "legacy_name_ordinal": legacy_snapshot["ordinal"], + "legacy_name_entry_sha256": legacy_snapshot["entry_sha256"], + "legacy_names_count": legacy_snapshot["names_count"], + "legacy_names_snapshot_sha256": legacy_snapshot["names_snapshot_sha256"], + "resulting_legacy_names_count": legacy_snapshot["names_count"] - 1, + "resulting_legacy_names_snapshot_sha256": legacy_snapshot[ + "resulting_names_snapshot_sha256" + ], + "canonical_has_name_count": canonical_names["canonical_has_name_count"], + "canonical_has_name_relation_ids": canonical_names[ + "canonical_has_name_relation_ids" + ], + "canonical_has_name_snapshot_sha256": canonical_names[ + "canonical_has_name_snapshot_sha256" + ], + } elif method in { "upsert_text_relation", "update_text_relation", @@ -1612,6 +1811,82 @@ def _text_read_back( } +def _legacy_name_read_back( + *, + concept_id: str, + intent_delta: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + snapshot = _legacy_name_document_snapshot(concept_id) + canonical_names = _canonical_has_name_snapshot(concept_id) + expected = intent_delta if isinstance(intent_delta, Mapping) else {} + expected_result_hash = _clean_text( + expected.get("resulting_legacy_names_snapshot_sha256") + ) + selected_entry_absent = bool(expected_result_hash) and ( + snapshot["names_snapshot_sha256"] == expected_result_hash + and snapshot["names_count"] == expected.get("resulting_legacy_names_count") + ) + return { + "concept_id": concept_id, + "legacy_name_selected_entry_absent": selected_entry_absent, + "legacy_names_count": snapshot["names_count"], + "legacy_names_snapshot_sha256": snapshot["names_snapshot_sha256"], + **canonical_names, + "publication_context": concept_publication_context(concept_id).to_mapping(), + } + + +def _delete_legacy_name_compare_and_set( + *, + concept_id: str, + precondition: Mapping[str, Any], +) -> dict[str, Any]: + """Atomically remove one selected legacy row without rewriting its peers.""" + + updated = ConceptsRepository.find_one_and_update( + { + "concept_id": concept_id, + "names": list(precondition["names"]), + }, + { + "$set": { + "names": list(precondition["resulting_names"]), + "embedding_status": "stale", + "updated_at": datetime.now(UTC), + } + }, + return_document=True, + ) + if not isinstance(updated, Mapping): + return { + "success": False, + "effect_status": "not_started", + "mutation_outcome": "not_started", + "changed": False, + "error_code": "legacy_name_compare_and_set_failed", + "error": ( + "The legacy name list changed before it could be updated; " + "reload the concept and select it again." + ), + } + try: + from .concept_service import _invalidate_concept_mutation_caches + + _invalidate_concept_mutation_caches() + except Exception: + logger.exception("Legacy-name cleanup cache invalidation failed") + return { + "success": True, + "changed": True, + "concept_id": concept_id, + "deleted_legacy_name_ordinal": precondition["ordinal"], + "deleted_legacy_name_entry_sha256": precondition["entry_sha256"], + "previous_legacy_names_snapshot_sha256": precondition["names_snapshot_sha256"], + "legacy_names_snapshot_sha256": precondition["resulting_names_snapshot_sha256"], + "legacy_names_count": len(precondition["resulting_names"]), + } + + def _uncertain_promotion_read_back( *, source_id: str, @@ -1711,6 +1986,17 @@ def canonical_read_back_for_method( ) if method == "remove_relationships_bulk": return _bulk_relationship_read_back(arguments) + if method == "delete_legacy_name": + result_map = result if isinstance(result, Mapping) else {} + return _legacy_name_read_back( + concept_id=_normalise_concept_id(arguments.get("concept_id")) or "", + intent_delta={ + "resulting_legacy_names_snapshot_sha256": result_map.get( + "legacy_names_snapshot_sha256" + ), + "resulting_legacy_names_count": result_map.get("legacy_names_count"), + }, + ) if method in { "upsert_text_relation", "update_text_relation", @@ -1836,6 +2122,22 @@ def _verify_method_postcondition( return bool(expected_hash) and exact_relation if method == "delete_text_relation": return canonical_state.get("relation_present") is False + if method == "delete_legacy_name": + return ( + result.get("success") is True + and canonical_state.get("legacy_name_selected_entry_absent") is True + and canonical_state.get("legacy_names_count") + == intent.delta.get("resulting_legacy_names_count") + and canonical_state.get("legacy_names_snapshot_sha256") + == intent.delta.get("resulting_legacy_names_snapshot_sha256") + and canonical_state.get("canonical_has_name_present") is True + and canonical_state.get("canonical_has_name_count") + == intent.delta.get("canonical_has_name_count") + and canonical_state.get("canonical_has_name_relation_ids") + == intent.delta.get("canonical_has_name_relation_ids") + and canonical_state.get("canonical_has_name_snapshot_sha256") + == intent.delta.get("canonical_has_name_snapshot_sha256") + ) if method == "create_concepts": concepts = canonical_state.get("concepts") expected_context = intent.publication_context.to_mapping() @@ -2199,6 +2501,49 @@ def execute_governed_ontology_method( result_holder: dict[str, Mapping[str, Any]] = {} def perform_locked() -> Mapping[str, Any]: + if method_name == "delete_legacy_name": + concept_id = _normalise_concept_id(arguments.get("concept_id")) or "" + try: + precondition = _validated_legacy_name_precondition( + concept_id=concept_id, + legacy_name_selector=arguments.get("legacy_name_selector"), + ) + canonical_names = _canonical_has_name_snapshot(concept_id) + except OntologyMutationCommandError as exc: + return _safe_command_error(exc) + if canonical_names["canonical_has_name_present"] is not True: + return _safe_command_error( + OntologyMutationCommandError( + "canonical_has_name_required_for_legacy_cleanup", + ( + "A legacy inline name can be removed only while a " + "canonical hasName relation exists." + ), + ) + ) + if ( + canonical_names["canonical_has_name_count"] + != intent.delta.get("canonical_has_name_count") + or canonical_names["canonical_has_name_relation_ids"] + != intent.delta.get("canonical_has_name_relation_ids") + or canonical_names["canonical_has_name_snapshot_sha256"] + != intent.delta.get("canonical_has_name_snapshot_sha256") + ): + return _safe_command_error( + OntologyMutationCommandError( + "canonical_has_name_precondition_failed", + ( + "The canonical hasName relation changed; reload the " + "concept before removing legacy data." + ), + ) + ) + result = _delete_legacy_name_compare_and_set( + concept_id=concept_id, + precondition=precondition, + ) + result_holder["result"] = result + return result if method_name == "merge_concepts": from .concept_merge_service import ( ConceptMergePlanError, @@ -2340,6 +2685,33 @@ def perform() -> Mapping[str, Any]: or [] if _clean_text(relation_id) ) + if ( + method_name + in { + "upsert_text_relation", + "update_text_relation", + "upsert_singleton_text_relation", + "delete_text_relation", + "delete_legacy_name", + } + and _clean_text(intent.predicate).removeprefix("#V#") == "hasName" + ): + concept_id = _normalise_concept_id( + arguments.get("concept_id") + or arguments.get("subject_concept_id") + ) + if concept_id: + resource_keys.add(f"ontology-concept-names:{concept_id}") + if method_name == "delete_legacy_name": + concept_id = _normalise_concept_id(arguments.get("concept_id")) + resource_keys.update( + f"ontology-text-relation:{relation_id}" + for relation_id in intent.delta.get( + "canonical_has_name_relation_ids" + ) + or [] + if _clean_text(relation_id) + ) if method_name == "create_concepts": expected_concept_id = _normalise_concept_id( intent.delta.get("expected_concept_id") @@ -2438,6 +2810,37 @@ def perform() -> Mapping[str, Any]: return outcome +def delete_legacy_name( + *, + concept_id: str, + legacy_name_selector: Mapping[str, Any], + request_id: str | None = None, +) -> dict[str, Any]: + """Govern and atomically remove one exact legacy inline ``names[]`` row.""" + + arguments: dict[str, Any] = { + "concept_id": concept_id, + "legacy_name_selector": dict(legacy_name_selector), + } + if _clean_text(request_id): + arguments["request_id"] = _clean_text(request_id) + return execute_governed_ontology_method( + method_name="delete_legacy_name", + arguments=arguments, + # The command service deliberately owns this mutation. This fallback + # can report no effect if the dedicated branch is ever disconnected; + # it cannot become a generic concept update. + mutate=lambda: { + "success": False, + "effect_status": "not_started", + "mutation_outcome": "not_started", + "changed": False, + "error_code": "legacy_name_dedicated_command_unavailable", + "error": "The dedicated legacy-name cleanup command is unavailable.", + }, + ) + + def issue_same_turn_method_delegation( *, method_name: str, @@ -2539,9 +2942,11 @@ def json_dumps_sorted(value: Any) -> str: "OntologyMutationCommandError", "build_ontology_mutation_intent", "canonical_read_back_for_method", + "delete_legacy_name", "execute_governed_ontology_method", "is_ontology_mutation_method", "issue_same_turn_method_delegation", + "legacy_name_selector_metadata", "normalise_governed_ontology_arguments", "reconcile_governed_ontology_postcondition", "resolve_governed_ontology_arguments", diff --git a/src/frontend/web/von_interface/static/js/apiService.js b/src/frontend/web/von_interface/static/js/apiService.js index 731643e5..fc66a4ad 100644 --- a/src/frontend/web/von_interface/static/js/apiService.js +++ b/src/frontend/web/von_interface/static/js/apiService.js @@ -238,11 +238,15 @@ export async function patchJson(url, data, opts = {}) { return res.json(); } -export async function deleteJson(url) { - const res = await fetch(url, { +export async function deleteJson(url, data) { + const options = { method: 'DELETE', headers: buildHeaders() - }); + }; + if (data !== undefined) { + options.body = JSON.stringify(data); + } + const res = await fetch(url, options); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } diff --git a/src/frontend/web/von_interface/static/js/conceptTab.js b/src/frontend/web/von_interface/static/js/conceptTab.js index c4a75ef2..056e0c90 100644 --- a/src/frontend/web/von_interface/static/js/conceptTab.js +++ b/src/frontend/web/von_interface/static/js/conceptTab.js @@ -276,6 +276,8 @@ function normaliseNameRecord(raw) { type: 'NL', relationId: null, textValueId: null, + storageKind: null, + legacyNameSelector: null, }; } if (typeof raw === 'string') { @@ -285,12 +287,14 @@ function normaliseNameRecord(raw) { type: 'NL', relationId: null, textValueId: null, + storageKind: null, + legacyNameSelector: null, }; } const context = raw.context || {}; const nameSource = raw.name ?? raw.text ?? ''; - const nameValue = (typeof nameSource === 'string' ? nameSource : String(nameSource || '')).trim(); + const nameValue = typeof nameSource === 'string' ? nameSource : String(nameSource ?? ''); const languageSource = raw.language ?? raw.lang ?? 'en-NZ'; const language = (typeof languageSource === 'string' ? languageSource : String(languageSource || 'en-NZ')).trim() || 'en-NZ'; const typeSource = raw.type ?? context.name_type ?? 'NL'; @@ -303,9 +307,43 @@ function normaliseNameRecord(raw) { type, relationId: raw.relation_id ?? raw.relationId ?? null, textValueId: raw.text_value_id ?? raw.textValueId ?? null, + storageKind: raw.storage_kind ?? raw.storageKind ?? null, + // This is an opaque optimistic-concurrency selector. Preserve the object + // exactly as supplied by the backend; in particular, never derive it from + // display text or normalise any of its values. + legacyNameSelector: raw.legacy_name_selector ?? raw.legacyNameSelector ?? null, }; } +function getExactRelationId(nameRecord) { + const relationId = nameRecord?.relationId; + return typeof relationId === 'string' && relationId.trim() ? relationId : null; +} + +function getExactLegacyNameSelector(nameRecord, conceptId) { + if (nameRecord?.storageKind !== 'legacy_inline') { + return null; + } + + const selector = nameRecord.legacyNameSelector; + if (!selector || typeof selector !== 'object' || Array.isArray(selector)) { + return null; + } + + const isSha256 = (value) => typeof value === 'string' && /^[0-9a-f]{64}$/i.test(value); + if ( + selector.concept_id !== conceptId + || !Number.isInteger(selector.ordinal) + || selector.ordinal < 0 + || !isSha256(selector.entry_sha256) + || !isSha256(selector.names_snapshot_sha256) + ) { + return null; + } + + return selector; +} + function setNamesStatusMessage(suffix, message, colour, autoClear = false) { const namesStatus = getSuffixElement('namesStatus', suffix); if (!namesStatus) { @@ -616,25 +654,6 @@ export async function executeConceptIdRename(suffix = '') { } } -async function deleteTextRelationByPredicate(conceptId, predicate, text, options = {}) { - const payload = { predicate, text }; - if (options.lang) { - payload.lang = options.lang; - } - if (options.context && typeof options.context === 'object') { - payload.context = options.context; - } - const res = await fetch(`/api/concepts/${encodeURIComponent(conceptId)}/texts`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - if (!res.ok) { - throw new Error(`HTTP ${res.status}`); - } - return res.json(); -} - function renderMissingConceptState(conceptId, suffix = '', options = {}) { const containerId = suffix ? `conceptTab_${suffix}` : 'conceptTab'; const container = document.getElementById(containerId); @@ -3134,9 +3153,17 @@ export async function displayConceptNames(names = [], suffix = '') { nameText.title = nameObj.name || nameObj.text || ''; const isCodeName = (nameObj.type || '').toString().trim().toUpperCase() === 'CODE'; + const isLegacyInlineName = nameObj.storageKind === 'legacy_inline'; + const canEditName = !isCodeName && !isLegacyInlineName && Boolean(getExactRelationId(nameObj)); + + if (isLegacyInlineName) { + nameText.title = 'Legacy inline names cannot be edited; remove this name and add a canonical name instead'; + } else if (!isCodeName && !canEditName) { + nameText.title = 'This name cannot be edited because its exact text relation is unavailable'; + } // Inline edit behaviour (JVNAUTOSCI-937): CODE names are read-only (for now). - if (!isCodeName) nameText.addEventListener('click', () => { + if (canEditName) nameText.addEventListener('click', () => { try { const original = nameObj.name || nameObj.text || ''; const input = document.createElement('input'); @@ -3156,13 +3183,13 @@ export async function displayConceptNames(names = [], suffix = '') { const finish = async (save) => { if (finished) return; // one-shot guard to avoid double-invoke on Enter+blur finished = true; - const newVal = (input.value || '').trim(); + const newVal = input.value || ''; // Restore text span in DOM cartouche.replaceChild(nameText, input); if (!save) { return; // cancelled } - if (!newVal) { + if (!newVal.trim()) { // Do not save empty strings; keep original return; } @@ -3327,15 +3354,25 @@ export async function deleteName(index, suffix = '') { return; } + const relationId = getExactRelationId(nameToDelete); + const legacyNameSelector = getExactLegacyNameSelector(nameToDelete, conceptId); + if (!relationId && !legacyNameSelector) { + setNamesStatusMessage( + suffix, + 'Cannot remove this name safely because its exact storage identifier is unavailable', + 'red' + ); + return; + } + try { setNamesStatusMessage(suffix, 'Deleting...', 'blue'); - if (nameToDelete?.relationId) { - await deleteJson(`/api/concepts/${encodeURIComponent(conceptId)}/texts/${encodeURIComponent(nameToDelete.relationId)}`); + if (relationId) { + await deleteJson(`/api/concepts/${encodeURIComponent(conceptId)}/texts/${encodeURIComponent(relationId)}`); } else { - await deleteTextRelationByPredicate(conceptId, 'hasName', nameToDelete.name, { - lang: nameToDelete.language, - context: { name_type: nameToDelete.type }, + await deleteJson(`/api/concepts/${encodeURIComponent(conceptId)}/legacy-names`, { + legacy_name_selector: legacyNameSelector, }); } @@ -3372,27 +3409,24 @@ async function updateConceptNameEntry(index, newValue, suffix = '') { } const lang = entry.language || 'en-NZ'; - const relationId = entry.relationId; + const relationId = getExactRelationId(entry); if (!relationId) { - // No relation id available, fallback by deleting + re-adding the name. - await deleteTextRelationByPredicate(conceptId, 'hasName', entry.name, { - lang: entry.language, - context: { name_type: entry.type }, - }); - await postJson(`/api/concepts/${encodeURIComponent(conceptId)}/texts`, { - predicate: 'hasName', - text: newValue, - lang, - context: { name_type: entry.type || 'NL' }, - }); - } else { - await patchJson(`/api/concepts/${encodeURIComponent(conceptId)}/texts/${encodeURIComponent(relationId)}`, { - text: newValue, - lang, - }); + setNamesStatusMessage( + suffix, + entry.storageKind === 'legacy_inline' + ? 'Legacy inline names cannot be edited; remove this name and add a canonical name instead' + : 'Cannot edit this name safely because its exact text relation is unavailable', + 'red' + ); + return; } + await patchJson(`/api/concepts/${encodeURIComponent(conceptId)}/texts/${encodeURIComponent(relationId)}`, { + text: newValue, + lang, + }); + await loadConceptNames(conceptId, suffix); setNamesStatusMessage(suffix, 'Name updated successfully', 'green', true); diff --git a/tests/backend/test_legacy_inline_name_cleanup.py b/tests/backend/test_legacy_inline_name_cleanup.py new file mode 100644 index 00000000..be87b3cf --- /dev/null +++ b/tests/backend/test_legacy_inline_name_cleanup.py @@ -0,0 +1,500 @@ +from __future__ import annotations + +from contextlib import contextmanager +from datetime import UTC, datetime +from typing import Any + +import mongomock +import pytest +from flask import Flask + + +@pytest.fixture +def legacy_name_runtime(monkeypatch: pytest.MonkeyPatch): + from src.backend.db.repositories import concepts_repository, text_value_repository + from src.backend.services import concept_service + from src.backend.services import ontology_mutation_command_service as command + from src.backend.services import ontology_publication_authority_service as authority + + database = mongomock.MongoClient()["legacy_inline_name_cleanup"] + concepts = database["concepts"] + text_relations = database["text_relations"] + text_values = database["text_values"] + delegations = database["delegations"] + receipts = database["receipts"] + delegations.create_index("delegation_id", unique=True) + receipts.create_index("receipt_id", unique=True) + receipts.create_index( + [("actor_concept_id", 1), ("idempotency_key", 1)], + unique=True, + partialFilterExpression={"idempotency_key": {"$exists": True}}, + ) + + monkeypatch.setattr( + concepts_repository, + "get_concepts_collection", + lambda: concepts, + ) + monkeypatch.setattr( + text_value_repository, + "get_text_relations_collection", + lambda: text_relations, + ) + monkeypatch.setattr( + text_value_repository, + "get_text_values_collection", + lambda: text_values, + ) + monkeypatch.setattr( + authority, + "get_ontology_authority_delegations_collection", + lambda: delegations, + ) + monkeypatch.setattr( + authority, + "get_ontology_mutation_receipts_collection", + lambda: receipts, + ) + monkeypatch.setattr(authority, "_gateway_actor_trust_source", lambda: None) + monkeypatch.setattr(authority, "resolve_live_semantic_roles", lambda _actor: ()) + monkeypatch.setattr(command, "can_access_concept", lambda _concept_id: True) + cache_invalidations: list[bool] = [] + monkeypatch.setattr( + concept_service, + "_invalidate_concept_mutation_caches", + lambda: cache_invalidations.append(True), + ) + return { + "authority": authority, + "command": command, + "concepts": concepts, + "text_relations": text_relations, + "text_values": text_values, + "receipts": receipts, + "cache_invalidations": cache_invalidations, + } + + +def _seed_concept( + runtime: dict[str, Any], + *, + concept_id: str = "#V#legacy_named_concept", + names: list[Any] | None = None, + canonical_name: str | None = "Canonical name", + global_scope: bool = False, +) -> tuple[str, list[Any]]: + stored_names = list( + names + if names is not None + else [ + {"name": "Malformed Legacy Name", "language": "en", "type": "NL"}, + { + "name": "Jožef Štefan — 研究 👩🏽‍🔬 e\u0301", + "language": "sl", + "type": "NL", + "annotation": "δοκιμή", + }, + ] + ) + relationships = {} if global_scope else {"#V#specific_to_user": ["#V#admin"]} + runtime["concepts"].insert_one( + { + "concept_id": concept_id, + "guid": f"guid:{concept_id}", + "created_at": datetime(2026, 8, 1, tzinfo=UTC), + "updated_at": datetime(2026, 8, 1, tzinfo=UTC), + "embedding_status": "indexed", + "names": stored_names, + "relationships": relationships, + } + ) + if canonical_name is not None: + runtime["text_values"].insert_one( + { + "_id": "canonical-name-text", + "text": canonical_name, + "lang": "en-NZ", + "provenance": {"source": "curated"}, + } + ) + runtime["text_relations"].insert_one( + { + "_id": "canonical-name-relation", + "subject_concept_id": concept_id, + "predicate": "hasName", + "object_text_id": "canonical-name-text", + "context": {"name_type": "NL"}, + } + ) + return concept_id, stored_names + + +def _delete( + runtime: dict[str, Any], + *, + concept_id: str, + names: list[Any], + ordinal: int, + request_id: str, +) -> dict[str, Any]: + command = runtime["command"] + selector = command.legacy_name_selector_metadata( + concept_id=concept_id, + names=names, + ordinal=ordinal, + ) + with runtime["authority"].override_current_actor("#V#admin", None): + return command.delete_legacy_name( + concept_id=concept_id, + legacy_name_selector=selector, + request_id=request_id, + ) + + +def test_governed_legacy_name_cleanup_is_exact_and_preserves_unicode( + legacy_name_runtime: dict[str, Any], +) -> None: + concept_id, names = _seed_concept(legacy_name_runtime) + canonical_before = legacy_name_runtime["text_relations"].find_one( + {"_id": "canonical-name-relation"} + ) + canonical_text_before = legacy_name_runtime["text_values"].find_one( + {"_id": "canonical-name-text"} + ) + preserved_utf8 = names[1]["name"].encode("utf-8") + + result = _delete( + legacy_name_runtime, + concept_id=concept_id, + names=names, + ordinal=0, + request_id="legacy-name:success", + ) + + assert result["success"] is True + assert result["authority_receipt"]["status"] == "succeeded" + assert result["canonical_read_back"]["legacy_name_selected_entry_absent"] is True + assert result["canonical_read_back"]["canonical_has_name_present"] is True + stored = legacy_name_runtime["concepts"].find_one({"concept_id": concept_id}) + assert stored["names"] == [names[1]] + assert stored["names"][0]["name"].encode("utf-8") == preserved_utf8 + assert stored["embedding_status"] == "stale" + assert stored["updated_at"].replace(tzinfo=UTC) > datetime(2026, 8, 1, tzinfo=UTC) + assert ( + legacy_name_runtime["text_relations"].find_one( + {"_id": "canonical-name-relation"} + ) + == canonical_before + ) + assert ( + legacy_name_runtime["text_values"].find_one({"_id": "canonical-name-text"}) + == canonical_text_before + ) + assert legacy_name_runtime["cache_invalidations"] == [True] + + +def test_stale_or_repeated_selector_is_refused_without_a_second_write( + legacy_name_runtime: dict[str, Any], +) -> None: + concept_id, names = _seed_concept(legacy_name_runtime) + first = _delete( + legacy_name_runtime, + concept_id=concept_id, + names=names, + ordinal=0, + request_id="legacy-name:first", + ) + second = _delete( + legacy_name_runtime, + concept_id=concept_id, + names=names, + ordinal=0, + request_id="legacy-name:duplicate", + ) + + assert first["success"] is True + assert second["success"] is False + assert second["error_code"] == "legacy_name_snapshot_precondition_failed" + assert legacy_name_runtime["concepts"].find_one({"concept_id": concept_id})[ + "names" + ] == [names[1]] + assert legacy_name_runtime["cache_invalidations"] == [True] + + +def test_selector_refuses_snapshot_drift_before_effect( + legacy_name_runtime: dict[str, Any], +) -> None: + concept_id, names = _seed_concept(legacy_name_runtime) + selector = legacy_name_runtime["command"].legacy_name_selector_metadata( + concept_id=concept_id, + names=names, + ordinal=0, + ) + legacy_name_runtime["concepts"].update_one( + {"concept_id": concept_id}, + {"$push": {"names": {"name": "Concurrent alias", "language": "en"}}}, + ) + + with legacy_name_runtime["authority"].override_current_actor("#V#admin", None): + result = legacy_name_runtime["command"].delete_legacy_name( + concept_id=concept_id, + legacy_name_selector=selector, + request_id="legacy-name:stale", + ) + + assert result["success"] is False + assert result["error_code"] == "legacy_name_snapshot_precondition_failed" + assert legacy_name_runtime["cache_invalidations"] == [] + + +def test_cleanup_refuses_to_remove_legacy_name_without_canonical_has_name( + legacy_name_runtime: dict[str, Any], +) -> None: + concept_id, names = _seed_concept(legacy_name_runtime, canonical_name=None) + + result = _delete( + legacy_name_runtime, + concept_id=concept_id, + names=names, + ordinal=0, + request_id="legacy-name:no-canonical", + ) + + assert result["success"] is False + assert result["error_code"] == "canonical_has_name_required_for_legacy_cleanup" + assert ( + legacy_name_runtime["concepts"].find_one({"concept_id": concept_id})["names"] + == names + ) + + +def test_cleanup_obeys_semantic_governance_denial( + legacy_name_runtime: dict[str, Any], +) -> None: + concept_id, names = _seed_concept(legacy_name_runtime, global_scope=True) + + result = _delete( + legacy_name_runtime, + concept_id=concept_id, + names=names, + ordinal=0, + request_id="legacy-name:denied", + ) + + assert result["success"] is False + assert result["error_code"] == "global_ontology_admin_authority_required" + assert ( + legacy_name_runtime["concepts"].find_one({"concept_id": concept_id})["names"] + == names + ) + assert legacy_name_runtime["cache_invalidations"] == [] + + +def test_legacy_cleanup_and_canonical_has_name_writes_share_the_concept_lock( + legacy_name_runtime: dict[str, Any], + monkeypatch: pytest.MonkeyPatch, +) -> None: + concept_id, names = _seed_concept(legacy_name_runtime) + command = legacy_name_runtime["command"] + acquired: list[str] = [] + + @contextmanager + def record_lock(resource_key: str): + acquired.append(resource_key) + yield + + monkeypatch.setattr(command, "ontology_mutation_resource_lock", record_lock) + legacy_result = _delete( + legacy_name_runtime, + concept_id=concept_id, + names=names, + ordinal=0, + request_id="legacy-name:shared-lock", + ) + expected_key = f"ontology-concept-names:{concept_id}" + assert legacy_result["success"] is True + assert expected_key in acquired + + acquired.clear() + with legacy_name_runtime["authority"].override_current_actor("#V#admin", None): + command.execute_governed_ontology_method( + method_name="upsert_text_relation", + arguments={ + "concept_id": concept_id, + "predicate": "hasName", + "text": "Another canonical name", + "language": "en-NZ", + "context": {"name_type": "NL"}, + "provenance": {"source": "test"}, + "request_id": "canonical-name:shared-lock", + }, + mutate=lambda: { + "success": False, + "changed": False, + "error_code": "test_no_effect", + }, + ) + assert expected_key in acquired + + +def test_http_delete_binds_the_enriched_selector_without_rewriting_it( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from src.backend.server.routes import concept_routes + + captured: dict[str, Any] = {} + + def delete(**kwargs: Any) -> dict[str, Any]: + captured.update(kwargs) + return {"success": True, "changed": True} + + monkeypatch.setattr(concept_routes, "delete_legacy_name", delete) + app = Flask(__name__) + app.secret_key = "test-secret" + app.register_blueprint(concept_routes.concept_bp, url_prefix="/api/concepts") + selector = { + "concept_id": "#V#unicode_研究", + "ordinal": 2, + "entry_sha256": "a" * 64, + "names_snapshot_sha256": "b" * 64, + } + + client = app.test_client() + with client.session_transaction() as flask_session: + flask_session["user_concept_id"] = "#V#admin" + response = client.delete( + "/api/concepts/%23V%23unicode_%E7%A0%94%E7%A9%B6/legacy-names", + json={ + "legacy_name_selector": selector, + "request_id": "legacy-name:http", + }, + ) + + assert response.status_code == 200 + assert captured == { + "concept_id": "#V#unicode_研究", + "legacy_name_selector": selector, + "request_id": "legacy-name:http", + } + + +def test_http_delete_requires_session_actor_then_executes_the_governed_command( + legacy_name_runtime: dict[str, Any], +) -> None: + from src.backend.server.routes import concept_routes + + concept_id, names = _seed_concept(legacy_name_runtime) + selector = legacy_name_runtime["command"].legacy_name_selector_metadata( + concept_id=concept_id, + names=names, + ordinal=0, + ) + app = Flask(__name__) + app.secret_key = "test-secret" + app.register_blueprint(concept_routes.concept_bp, url_prefix="/api/concepts") + client = app.test_client() + path = "/api/concepts/%23V%23legacy_named_concept/legacy-names" + + denied = client.delete( + path, + json={"legacy_name_selector": selector, "request_id": "http:denied"}, + ) + assert denied.status_code == 403 + assert denied.get_json()["error_code"] == "authenticated_actor_context_required" + + with client.session_transaction() as flask_session: + flask_session["user_concept_id"] = "#V#admin" + accepted = client.delete( + path, + json={"legacy_name_selector": selector, "request_id": "http:accepted"}, + ) + assert accepted.status_code == 200 + assert accepted.get_json()["canonical_read_back"][ + "legacy_name_selected_entry_absent" + ] is True + + +def test_http_delete_rejects_client_supplied_actor_before_command_preflight( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from src.backend.security import access_control + from src.backend.server.routes import concept_routes + + command_called = False + + def unexpected_delete(**_kwargs: Any) -> dict[str, Any]: + nonlocal command_called + command_called = True + return {"success": True, "changed": True} + + monkeypatch.setattr( + access_control, + "get_effective_user_concept_id_with_source", + lambda: ( + "#V#client_supplied_actor", + access_control.LEGACY_IDENTITY_HEADER_ACTOR_SOURCE, + ), + ) + monkeypatch.setattr(concept_routes, "delete_legacy_name", unexpected_delete) + app = Flask(__name__) + app.secret_key = "test-secret" + app.register_blueprint(concept_routes.concept_bp, url_prefix="/api/concepts") + + response = app.test_client().delete( + "/api/concepts/%23V%23legacy_named_concept/legacy-names", + json={ + "legacy_name_selector": { + "concept_id": "#V#legacy_named_concept", + "ordinal": 0, + "entry_sha256": "a" * 64, + "names_snapshot_sha256": "b" * 64, + } + }, + ) + + assert response.status_code == 403 + assert response.get_json()["error_code"] == "authenticated_actor_context_required" + assert command_called is False + + +def test_enrichment_exposes_exact_legacy_selector_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from src.backend.services import concept_service, text_value_service + from src.backend.services.ontology_mutation_command_service import ( + legacy_name_selector_metadata, + ) + + concept_id = "#V#unicode_projection" + legacy_names = [ + {"name": "Jožef Štefan — 研究 👩🏽‍🔬 e\u0301", "language": "sl", "type": "NL"} + ] + monkeypatch.setattr( + text_value_service, + "get_texts_for_concepts", + lambda concept_ids, **_kwargs: { + concept_ids[0]: [ + { + "text": "Canonical name", + "lang": "en-NZ", + "predicate": "hasName", + "context": {"name_type": "NL"}, + "relation_id": "canonical-relation", + } + ] + }, + ) + + enriched = concept_service.enrich_concept_with_text_relations( + {"concept_id": concept_id, "names": legacy_names} + ) + + assert enriched["names"][0]["storage_kind"] == "text_relation" + legacy = enriched["names"][1] + assert legacy["name"] == legacy_names[0]["name"] + assert legacy["storage_kind"] == "legacy_inline" + assert legacy["legacy_name_selector"] == legacy_name_selector_metadata( + concept_id=concept_id, + names=legacy_names, + ordinal=0, + ) diff --git a/tests/frontend/apiServiceUserContext.test.js b/tests/frontend/apiServiceUserContext.test.js index b36b156e..b7177e30 100644 --- a/tests/frontend/apiServiceUserContext.test.js +++ b/tests/frontend/apiServiceUserContext.test.js @@ -1,6 +1,6 @@ /** @jest-environment jsdom */ -import { getUserContext } from '../../src/frontend/web/von_interface/static/js/apiService.js'; +import { deleteJson, getUserContext } from '../../src/frontend/web/von_interface/static/js/apiService.js'; describe('apiService user context resource-scope isolation', () => { beforeEach(() => { @@ -11,6 +11,7 @@ describe('apiService user context resource-scope isolation', () => { afterEach(() => { window.localStorage.clear(); window.sessionStorage.clear(); + delete global.fetch; }); test('does not expose the browser-wide Gmail Settings profile as request context', () => { @@ -29,4 +30,29 @@ describe('apiService user context resource-scope isolation', () => { })); expect(context).not.toHaveProperty('gmail_profile'); }); + + test('DELETE forwards an optional JSON body with the actor-bound window session header', async () => { + global.fetch = jest.fn(async () => ({ + ok: true, + json: async () => ({ success: true }), + })); + const selector = { + concept_id: '#V#študent_博士', + ordinal: 0, + entry_sha256: 'a'.repeat(64), + names_snapshot_sha256: 'b'.repeat(64), + }; + + await deleteJson('/api/concepts/example/legacy-names', { + legacy_name_selector: selector, + }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + const [url, options] = global.fetch.mock.calls[0]; + expect(url).toBe('/api/concepts/example/legacy-names'); + expect(options.method).toBe('DELETE'); + expect(options.headers['Content-Type']).toBe('application/json'); + expect(options.headers['X-Von-Window-Session']).toMatch(/^ws_/); + expect(JSON.parse(options.body)).toEqual({ legacy_name_selector: selector }); + }); }); diff --git a/tests/frontend/conceptTabNameResolution.test.js b/tests/frontend/conceptTabNameResolution.test.js index eed2131d..234ef00a 100644 --- a/tests/frontend/conceptTabNameResolution.test.js +++ b/tests/frontend/conceptTabNameResolution.test.js @@ -2,13 +2,14 @@ import { addNewName, + deleteName, displayConceptNames, executeConceptIdRename, fetchSubtypesWithSuffix, loadConceptNames, previewConceptIdRename, } from "../../src/frontend/web/von_interface/static/js/conceptTab.js"; -import { getJson, patchJson, postJson } from "../../src/frontend/web/von_interface/static/js/apiService.js"; +import { deleteJson, getJson, patchJson, postJson } from "../../src/frontend/web/von_interface/static/js/apiService.js"; jest.mock('../../src/frontend/web/von_interface/static/js/apiService.js', () => ({ deleteJson: jest.fn(), @@ -173,6 +174,7 @@ describe('conceptTab name resolution', () => { ['博士研究生', 'zh'], ['طالبة دكتوراه', 'ar'], ['E\u0301tudiante en IA', 'fr'], + ['\u00a0विद्यार्थी E\u0301\u00a0', 'hi'], ])('opens the name editor without reformatting %s', async (storedName, language) => { document.body.innerHTML = `
@@ -204,6 +206,117 @@ describe('conceptTab name resolution', () => { expect(patchJson).not.toHaveBeenCalled(); }); + test('deletes a relation-backed name only by its exact relation id', async () => { + deleteJson.mockResolvedValue({ success: true }); + await displayConceptNames([ + { + name: 'Relation-backed name', + language: 'en-NZ', + type: 'NL', + storage_kind: 'text_relation', + relation_id: 'relation/name:1', + }, + { + name: 'Name to retain', + language: 'en-NZ', + type: 'NL', + storage_kind: 'text_relation', + relation_id: 'relation-2', + }, + ], 'alpha'); + + await deleteName(0, 'alpha'); + + expect(deleteJson).toHaveBeenCalledTimes(1); + expect(deleteJson).toHaveBeenCalledWith( + '/api/concepts/%23V%23concept_alpha/texts/relation%2Fname%3A1' + ); + }); + + test('forwards an exact legacy selector unchanged while preserving Unicode display text', async () => { + const conceptId = '#V#študent_博士'; + document.body.innerHTML = ` +
+
+ +
+ `; + const selector = Object.freeze({ + concept_id: conceptId, + ordinal: 0, + entry_sha256: 'a'.repeat(64), + names_snapshot_sha256: 'b'.repeat(64), + }); + const storedName = 'Študentka 博士研究生 — طالبة دكتوراه'; + deleteJson.mockResolvedValue({ success: true }); + + await displayConceptNames([ + { + name: storedName, + language: 'sl', + type: 'NL', + storage_kind: 'legacy_inline', + legacy_name_selector: selector, + }, + { + name: 'Canonical name', + language: 'en-NZ', + type: 'NL', + storage_kind: 'text_relation', + relation_id: 'relation-2', + }, + ], 'alpha'); + + const legacyText = Array.from(document.querySelectorAll('#namesList_alpha .name-text')) + .find((element) => element.textContent === storedName); + expect(legacyText).toBeDefined(); + expect(legacyText.dir).toBe('auto'); + expect(legacyText.title).toContain('remove this name and add a canonical name'); + legacyText.click(); + expect(document.querySelector('#namesList_alpha .name-edit-input')).toBeNull(); + + const renderedCartouches = Array.from(document.querySelectorAll('#namesList_alpha .name-cartouche')); + const legacyIndex = renderedCartouches.indexOf(legacyText.closest('.name-cartouche')); + await deleteName(legacyIndex, 'alpha'); + + expect(deleteJson).toHaveBeenCalledTimes(1); + expect(deleteJson.mock.calls[0][0]).toBe( + '/api/concepts/%23V%23%C5%A1tudent_%E5%8D%9A%E5%A3%AB/legacy-names' + ); + expect(deleteJson.mock.calls[0][1]).toEqual({ legacy_name_selector: selector }); + expect(deleteJson.mock.calls[0][1].legacy_name_selector).toBe(selector); + }); + + test('fails locally when a name has neither an exact relation id nor an exact legacy selector', async () => { + await displayConceptNames([ + { + name: 'Unidentifiable legacy name', + language: 'en-NZ', + type: 'NL', + storage_kind: 'legacy_inline', + legacy_name_selector: { + concept_id: '#V#concept_alpha', + ordinal: 0, + entry_sha256: 'incomplete', + }, + }, + { + name: 'Name to retain', + language: 'en-NZ', + type: 'NL', + storage_kind: 'text_relation', + relation_id: 'relation-2', + }, + ], 'alpha'); + + await deleteName(0, 'alpha'); + + expect(deleteJson).not.toHaveBeenCalled(); + expect(document.getElementById('namesStatus_alpha').textContent).toBe( + 'Cannot remove this name safely because its exact storage identifier is unavailable' + ); + }); + test('previews concept ID rename and enables execution only after a successful preview', async () => { document.body.innerHTML = `