Files
library-desk/tests/test_entity_linking.py
T
jpmschweitzerandClaude 748d1cbfae test(integration): mark the 20 tests that need Neo4j and give them a runnable home
D-26 requires `make test` to pass with no network; T-55's audit measured 426
passed/29 skipped with network vs. 412 passed/23 skipped/20 ERRORS inside an
unprivileged network namespace. All 20 errors trace to a real Bolt connection
opened at fixture setup (neo4j_client -> client.connect()), not to test logic.

The ticket's own summary said all 20 were in test_entity_linking.py; tracing
the actual error list showed only 5 were (TestEntityLinkingIntegration,
TestEntityLinkingMultiTenancy, plus the trailing module-level cleanup test).
The other 15 are every test in test_hybrid_rag.py, whose hybrid_rag_service
fixture resolves graph_service -> neo4j_client regardless of what the test
body itself exercises -- including the RRF-fusion and context-formatting
classes that read as pure logic. There is no unit/integration split inside
that file without restructuring its fixture graph, which is out of scope
here; the whole module is marked instead of picking classes apart from
underneath a shared fixture chain.

The fix is the mechanism this repo already had and had never wired to a
target: tests/conftest.py's `integration` pytest marker plus its
RUN_INTEGRATION_TESTS/TEST_TENANT gate (test_integration.py,
test_tenant_isolation_live.py, test_quality_report_live.py and
TestWikiChangeListenerIntegration already used it). Applying the same marker
here means `make test` skips these 20 the same way it already skipped the
other 23 -- no file move, no new fixture layer, matching repo precedent
exactly rather than inventing a second convention beside it.

`make test-integration` is the D-26 home: sets RUN_INTEGRATION_TESTS=1,
selects `-m integration`, and treats pytest's own "no tests collected" exit
code (5) as a hard failure rather than a pass, so a marker that gets renamed
or lost fails loudly instead of the target quietly collecting zero and going
green.

Verified (unshare -rn sh -c 'ip link set lo up; ...' after confirming the
positive control -- a live :8089 returning HTTP 200 outside returns curl exit
7 inside):
  make test, no network:   412 passed, 43 skipped, exit 0  (was 20 ERRORS)
  make test, with network: 412 passed, 43 skipped, exit 0  (unchanged; the 14
    of these 20 that were previously counted in the 426 passed now skip by
    default -- reclassified, not lost; the other 6 already skipped for an
    unrelated reason before this change)
  make test-integration, these 20, with network: 14 passed, 6 skipped
    (test_wiki_page's own pytest.skip when it can't create a wiki page -- a
    pre-existing soft-skip, unrelated to this change), 0 failed, exit 0
  make test-integration mutated to select a nonexistent marker: FAIL,
    "selected 0 tests", exit 2 -- confirmed loud, then reverted

Not fixed here: the other 23 tests already carrying `integration` include
three files (test_integration.py, test_tenant_isolation_live.py,
test_quality_report_live.py) that fail under `make test-integration` today
because they call the local dev server on :8778, which was not running in
this session -- a pre-existing "never proven runnable" gap this same ticket
family exists to find, but a different set of tests than the one measured
here.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 15:40:41 +02:00

487 lines
16 KiB
Python

"""
Comprehensive tests for Entity Linking system.
Tests cover:
- Finding entity mentions in pages
- Creating MENTIONS relationships in Neo4j
- Adding hyperlinks to wiki content
- Idempotency (safe to run multiple times)
- Protection of existing links (no nesting)
- Multi-tenancy isolation
Run with: pytest tests/test_entity_linking.py -v -s
"""
import pytest
import pytest_asyncio
from typing import AsyncGenerator
from src.clients.neo4j_client import Neo4jClient
from src.clients.wikijs_client import WikiJSClient
from src.services.graph_service import GraphService
from src.services.wiki_service import WikiService
from src.routers.entity_linking import (
find_entity_mentions,
add_entity_links_to_content,
get_entities_with_paths
)
from src.config import get_settings
# Test user to isolate test data
TEST_USER = "entity-link-tester"
@pytest.fixture
def settings():
"""Get application settings."""
return get_settings()
@pytest_asyncio.fixture
async def neo4j_client(settings, neo4j_test_uri) -> AsyncGenerator[Neo4jClient, None]:
"""Get connected Neo4j client."""
client = Neo4jClient(
uri=neo4j_test_uri,
user=settings.neo4j_user,
password=settings.neo4j_password
)
await client.connect()
yield client
await client.close()
@pytest_asyncio.fixture
async def wiki_client(wikijs_test_config) -> AsyncGenerator[WikiJSClient, None]:
"""Get Wiki.js client."""
client = WikiJSClient(
base_url=wikijs_test_config["base_url"],
api_token=wikijs_test_config["api_token"]
)
yield client
@pytest_asyncio.fixture
async def graph_service(neo4j_client, wiki_client):
"""Get GraphService instance."""
return GraphService(neo4j_client, wiki_client)
@pytest_asyncio.fixture
async def wiki_service(wiki_client):
"""Get WikiService instance."""
return WikiService(wiki_client)
@pytest_asyncio.fixture
async def test_entities(graph_service):
"""Create test entities in graph."""
from src.core.multi_tenancy import get_neo4j_user_base_label
user_base_label = get_neo4j_user_base_label(TEST_USER)
# Clean up any existing test entities
cleanup_query = f"""
MATCH (n:{user_base_label})
WHERE n.name IN ['Docker', 'Kubernetes', 'PostgreSQL']
DETACH DELETE n
"""
await graph_service.neo4j.execute_query(cleanup_query)
# Create test entities
create_query = f"""
CREATE (d:{user_base_label}:Technology {{name: 'Docker', type: 'technology'}})
CREATE (k:{user_base_label}:Technology {{name: 'Kubernetes', type: 'technology'}})
CREATE (p:{user_base_label}:Technology {{name: 'PostgreSQL', type: 'technology'}})
RETURN d.name, k.name, p.name
"""
await graph_service.neo4j.execute_query(create_query)
yield ["Docker", "Kubernetes", "PostgreSQL"]
# Cleanup after test
await graph_service.neo4j.execute_query(cleanup_query)
# ============================================================================
# Unit Tests - Entity Mention Detection
# ============================================================================
class TestFindEntityMentions:
"""Test finding entity mentions in content."""
def test_find_single_mention(self):
"""Test finding a single entity mention."""
content = "Docker is a containerization platform."
entities = [
{"name": "Docker", "type": "technology"}
]
found = find_entity_mentions(content, entities)
assert len(found) == 1
assert found[0]["name"] == "Docker"
assert found[0]["mentions"] == 1
def test_find_multiple_mentions(self):
"""Test finding multiple mentions of same entity."""
content = "Docker containers run on Docker Engine. Docker is great!"
entities = [
{"name": "Docker", "type": "technology"}
]
found = find_entity_mentions(content, entities)
assert len(found) == 1
assert found[0]["name"] == "Docker"
assert found[0]["mentions"] == 3
def test_case_insensitive_matching(self):
"""Test case-insensitive entity matching."""
content = "docker and DOCKER and Docker are the same"
entities = [
{"name": "Docker", "type": "technology"}
]
found = find_entity_mentions(content, entities)
assert len(found) == 1
assert found[0]["mentions"] == 3
def test_whole_word_matching(self):
"""Test that partial word matches are excluded."""
content = "Kubernetes and Kubernetes-based and MyKubernetes"
entities = [
{"name": "Kubernetes", "type": "technology"}
]
found = find_entity_mentions(content, entities)
assert len(found) == 1
# Regex \b matches at hyphens, so "Kubernetes-based" contains "Kubernetes"
# Only "MyKubernetes" is excluded (no word boundary)
assert found[0]["mentions"] == 2 # "Kubernetes" and "Kubernetes-based"
def test_ignore_short_names(self):
"""Test that entities with names <3 chars are ignored."""
content = "Go is a programming language by Google"
entities = [
{"name": "Go", "type": "language"}, # Too short
{"name": "Google", "type": "organization"}
]
found = find_entity_mentions(content, entities)
assert len(found) == 1
assert found[0]["name"] == "Google"
def test_sort_by_mention_count(self):
"""Test results are sorted by mention count."""
content = "Docker Docker Docker. Kubernetes Kubernetes. PostgreSQL."
entities = [
{"name": "PostgreSQL", "type": "database"},
{"name": "Docker", "type": "technology"},
{"name": "Kubernetes", "type": "technology"}
]
found = find_entity_mentions(content, entities)
assert len(found) == 3
assert found[0]["name"] == "Docker" # Most mentions
assert found[0]["mentions"] == 3
assert found[1]["name"] == "Kubernetes"
assert found[1]["mentions"] == 2
assert found[2]["name"] == "PostgreSQL"
assert found[2]["mentions"] == 1
# ============================================================================
# Unit Tests - Content Link Addition
# ============================================================================
class TestAddEntityLinksToContent:
"""Test adding hyperlinks to content."""
def test_add_single_link(self):
"""Test adding a single entity link."""
content = "Docker is a containerization platform."
entities = [
{"name": "Docker", "path": "users/test/docker"}
]
updated, count = add_entity_links_to_content(content, entities)
assert count == 1
assert "[Docker](/users/test/docker)" in updated
def test_add_multiple_instances(self):
"""Test linking all instances of an entity."""
content = "Docker containers run on Docker Engine."
entities = [
{"name": "Docker", "path": "users/test/docker"}
]
updated, count = add_entity_links_to_content(content, entities)
assert count == 2 # Both instances linked
assert updated.count("[Docker](/users/test/docker)") == 2
def test_skip_entities_without_path(self):
"""Test that entities without wiki pages are not linked."""
content = "Docker and Kubernetes are used together."
entities = [
{"name": "Docker", "path": "users/test/docker"},
{"name": "Kubernetes", "path": None} # No page
]
updated, count = add_entity_links_to_content(content, entities)
assert count == 1 # Only Docker
assert "[Docker](/users/test/docker)" in updated
assert "[Kubernetes]" not in updated
def test_protect_existing_links(self):
"""Test that existing markdown links are not modified."""
content = "See [Docker](https://docker.com) for more info. Docker is great!"
entities = [
{"name": "Docker", "path": "users/test/docker"}
]
updated, count = add_entity_links_to_content(content, entities)
# Should link the second "Docker" but not the one already linked
assert count == 1
assert "[Docker](https://docker.com)" in updated # Preserved
assert updated.count("[Docker](/users/test/docker)") == 1
def test_no_nested_links(self):
"""Test that entity names in URLs are not linked."""
content = "Check [Docker Hub](/docker/hub) for images."
entities = [
{"name": "Docker", "path": "users/test/docker"}
]
updated, count = add_entity_links_to_content(content, entities)
# "Docker" in the URL path should not be linked
assert count == 0
assert "[Docker Hub](/docker/hub)" in updated # Unchanged
def test_longest_first_matching(self):
"""Test that longer entity names are matched first."""
content = "Machine Learning and Machine are different."
entities = [
{"name": "Machine Learning", "path": "users/test/ml"},
{"name": "Machine", "path": "users/test/machine"}
]
updated, count = add_entity_links_to_content(content, entities)
# Should link "Machine Learning" first, leaving "Machine" alone
assert "[Machine Learning](/users/test/ml)" in updated
assert count >= 1
# ============================================================================
# Integration Tests - Full Entity Linking Flow
# ============================================================================
@pytest.mark.integration
class TestEntityLinkingIntegration:
"""Test full entity linking flow."""
@pytest.mark.asyncio
async def test_get_entities_with_paths(self, graph_service, test_entities):
"""Test retrieving entities and their wiki page paths."""
entities = await get_entities_with_paths(graph_service, TEST_USER)
# Should find our test entities
entity_names = [e["name"] for e in entities]
assert "Docker" in entity_names
assert "Kubernetes" in entity_names
assert "PostgreSQL" in entity_names
@pytest.mark.asyncio
async def test_create_mentions_relationships(self, graph_service, test_entities):
"""Test creating MENTIONS relationships."""
from src.core.multi_tenancy import get_neo4j_user_label
user_doc_label = get_neo4j_user_label(TEST_USER)
# Create a test document node
doc_query = f"""
CREATE (d:{user_doc_label}:Document {{
page_id: 9999,
title: 'Test Doc',
path: 'users/test/doc'
}})
RETURN d
"""
await graph_service.neo4j.execute_query(doc_query)
# Create MENTIONS relationships
found_entities = [
{"name": "Docker"},
{"name": "Kubernetes"}
]
new_links = await graph_service.create_entity_mentions(
page_id=9999,
user=TEST_USER,
entity_names=found_entities
)
assert new_links == 2
# Verify relationships exist
verify_query = f"""
MATCH (d:{user_doc_label}:Document {{page_id: 9999}})-[r:MENTIONS]->(e)
RETURN count(r) as mention_count
"""
result = await graph_service.neo4j.execute_query(verify_query)
assert result[0]["mention_count"] == 2
# Cleanup
cleanup_query = f"""
MATCH (d:{user_doc_label}:Document {{page_id: 9999}})
DETACH DELETE d
"""
await graph_service.neo4j.execute_query(cleanup_query)
@pytest.mark.asyncio
async def test_idempotency(self, graph_service):
"""Test that entity linking is idempotent."""
from src.core.multi_tenancy import get_neo4j_user_label, get_neo4j_user_base_label
user_doc_label = get_neo4j_user_label(TEST_USER)
user_base_label = get_neo4j_user_base_label(TEST_USER)
# Aggressively clean up ALL test data first (fresh start)
cleanup_all = f"""
MATCH (n)
WHERE (n:{user_base_label} OR n:{user_doc_label})
AND (n.page_id = 9998 OR n.name = 'TestDockerEntity')
DETACH DELETE n
"""
await graph_service.neo4j.execute_query(cleanup_all)
# Create a unique test entity
entity_query = f"""
CREATE (e:{user_base_label}:Technology {{name: 'TestDockerEntity', type: 'technology'}})
RETURN e
"""
await graph_service.neo4j.execute_query(entity_query)
# Create test document
doc_query = f"""
CREATE (d:{user_doc_label}:Document {{
page_id: 9998,
title: 'Test Doc 2',
path: 'users/test/doc2'
}})
RETURN d
"""
await graph_service.neo4j.execute_query(doc_query)
found_entities = [{"name": "TestDockerEntity"}]
# Link once
first_run = await graph_service.create_entity_mentions(
page_id=9998,
user=TEST_USER,
entity_names=found_entities
)
assert first_run == 1
# Link again - should not create duplicates
second_run = await graph_service.create_entity_mentions(
page_id=9998,
user=TEST_USER,
entity_names=found_entities
)
assert second_run == 0 # No new links
# Verify only one relationship exists
verify_query = f"""
MATCH (d:{user_doc_label}:Document {{page_id: 9998}})-[r:MENTIONS]->()
RETURN count(r) as mention_count
"""
result = await graph_service.neo4j.execute_query(verify_query)
assert result[0]["mention_count"] == 1
# Cleanup
cleanup_query = f"""
MATCH (n)
WHERE (n:{user_base_label} OR n:{user_doc_label})
AND (n.page_id = 9998 OR n.name = 'TestDockerEntity')
DETACH DELETE n
"""
await graph_service.neo4j.execute_query(cleanup_query)
# ============================================================================
# Multi-Tenancy Tests
# ============================================================================
@pytest.mark.integration
class TestEntityLinkingMultiTenancy:
"""Test multi-tenancy isolation in entity linking."""
@pytest.mark.asyncio
async def test_user_isolation(self, graph_service):
"""Test that entities are isolated by user."""
from src.core.multi_tenancy import get_neo4j_user_base_label
user1_label = get_neo4j_user_base_label("user1")
user2_label = get_neo4j_user_base_label("user2")
# Create entity for user1
create_user1 = f"""
CREATE (e:{user1_label}:Technology {{name: 'Docker', type: 'technology'}})
RETURN e
"""
await graph_service.neo4j.execute_query(create_user1)
# Create entity for user2
create_user2 = f"""
CREATE (e:{user2_label}:Technology {{name: 'Docker', type: 'technology'}})
RETURN e
"""
await graph_service.neo4j.execute_query(create_user2)
# Get entities for user1 - should only see user1's entities
entities_user1 = await get_entities_with_paths(graph_service, "user1")
entity_names_user1 = [e["name"] for e in entities_user1]
# Verify isolation
assert "Docker" in entity_names_user1
# We can't verify the exact count without knowing what else is in the DB,
# but we verified we can retrieve entities for user1
# Cleanup
await graph_service.neo4j.execute_query(f"MATCH (e:{user1_label}) WHERE e.name = 'Docker' DETACH DELETE e")
await graph_service.neo4j.execute_query(f"MATCH (e:{user2_label}) WHERE e.name = 'Docker' DETACH DELETE e")
# ============================================================================
# Cleanup
# ============================================================================
@pytest.mark.integration
@pytest.mark.asyncio
async def test_cleanup_entity_linking_test_data(neo4j_client):
"""Clean up all test data created by entity linking tests."""
from src.core.multi_tenancy import get_neo4j_user_base_label
for user in [TEST_USER, "user1", "user2"]:
user_label = get_neo4j_user_base_label(user)
cleanup_query = f"""
MATCH (n:{user_label})
WHERE n.page_id IN [9999, 9998]
OR n.name IN ['Docker', 'Kubernetes', 'PostgreSQL']
DETACH DELETE n
"""
await neo4j_client.execute_query(cleanup_query)
print("\n✓ Cleaned up entity linking test data")