367 lines
13 KiB
Python
367 lines
13 KiB
Python
"""Tests for LinkRegistry – Web-Link-Management als Wissensartefakte.
|
||
|
||
Requirements: 8.1, 8.2, 8.4, 8.5, 8.6, 8.7
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
from unittest.mock import patch
|
||
|
||
import pytest
|
||
|
||
from monorepo.knowledge.sources.link import LinkRegistry
|
||
|
||
|
||
@pytest.fixture
|
||
def context_path(tmp_path: Path) -> Path:
|
||
"""Create a minimal knowledge context path."""
|
||
knowledge = tmp_path / "bahn" / "knowledge"
|
||
knowledge.mkdir(parents=True)
|
||
return knowledge
|
||
|
||
|
||
@pytest.fixture
|
||
def registry(context_path: Path) -> LinkRegistry:
|
||
"""Create a LinkRegistry instance."""
|
||
return LinkRegistry(context_path)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# save_link tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestSaveLink:
|
||
"""Tests for save_link."""
|
||
|
||
def test_save_link_creates_file(
|
||
self, registry: LinkRegistry, context_path: Path
|
||
) -> None:
|
||
"""Req 8.1: Links stored as artifacts of type link in links/ folder."""
|
||
with patch.object(registry, "_fetch_metadata", return_value=("Example Page", "A description")):
|
||
result = registry.save_link("https://example.com", title="Example")
|
||
|
||
assert result.exists()
|
||
assert result.parent == context_path / "links"
|
||
assert result.suffix == ".md"
|
||
|
||
def test_save_link_frontmatter_contains_required_fields(
|
||
self, registry: LinkRegistry, context_path: Path
|
||
) -> None:
|
||
"""Req 8.2: Frontmatter must contain url, title, tags, created, type: link."""
|
||
with patch.object(registry, "_fetch_metadata", return_value=("", "")):
|
||
result = registry.save_link(
|
||
"https://example.com/page",
|
||
title="My Link",
|
||
tags=["python", "docs"],
|
||
)
|
||
|
||
content = result.read_text(encoding="utf-8")
|
||
assert "type: link" in content
|
||
assert "title: My Link" in content
|
||
assert "url: https://example.com/page" in content
|
||
assert "python" in content
|
||
assert "docs" in content
|
||
assert "created:" in content
|
||
|
||
def test_save_link_with_tags(
|
||
self, registry: LinkRegistry, context_path: Path
|
||
) -> None:
|
||
"""Tags are included in the frontmatter."""
|
||
with patch.object(registry, "_fetch_metadata", return_value=("", "")):
|
||
result = registry.save_link(
|
||
"https://example.com",
|
||
title="Test",
|
||
tags=["tag1", "tag2"],
|
||
)
|
||
|
||
content = result.read_text(encoding="utf-8")
|
||
assert "tag1" in content
|
||
assert "tag2" in content
|
||
|
||
def test_save_link_empty_tags(
|
||
self, registry: LinkRegistry, context_path: Path
|
||
) -> None:
|
||
"""Links can be saved without tags."""
|
||
with patch.object(registry, "_fetch_metadata", return_value=("", "")):
|
||
result = registry.save_link("https://example.com", title="No Tags")
|
||
|
||
assert result.exists()
|
||
content = result.read_text(encoding="utf-8")
|
||
assert "tags: []" in content
|
||
|
||
def test_save_link_uses_fetched_title_if_none(
|
||
self, registry: LinkRegistry, context_path: Path
|
||
) -> None:
|
||
"""Req 8.4: Fetch title from URL when not provided."""
|
||
with patch.object(
|
||
registry, "_fetch_metadata", return_value=("Fetched Title", "Desc")
|
||
):
|
||
result = registry.save_link("https://example.com")
|
||
|
||
content = result.read_text(encoding="utf-8")
|
||
assert "Fetched Title" in content
|
||
|
||
def test_save_link_fetch_failed_sets_flag(
|
||
self, registry: LinkRegistry, context_path: Path
|
||
) -> None:
|
||
"""Req 8.5: Set fetch_failed: true if URL is unreachable."""
|
||
with patch.object(registry, "_fetch_metadata", return_value=("", "")):
|
||
result = registry.save_link("https://unreachable.example.com")
|
||
|
||
content = result.read_text(encoding="utf-8")
|
||
assert "fetch_failed: true" in content
|
||
|
||
def test_save_link_creates_links_directory(
|
||
self, registry: LinkRegistry, context_path: Path
|
||
) -> None:
|
||
"""links/ directory is created if it doesn't exist."""
|
||
assert not (context_path / "links").exists()
|
||
|
||
with patch.object(registry, "_fetch_metadata", return_value=("", "")):
|
||
result = registry.save_link("https://example.com", title="Test")
|
||
|
||
assert (context_path / "links").exists()
|
||
assert result.exists()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Deduplication tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestDeduplication:
|
||
"""Tests for URL deduplication with tag merging. Req 8.7."""
|
||
|
||
def test_duplicate_url_merges_tags(
|
||
self, registry: LinkRegistry, context_path: Path
|
||
) -> None:
|
||
"""Req 8.7: Duplicate URLs merge tags instead of creating new artifact."""
|
||
with patch.object(registry, "_fetch_metadata", return_value=("Page", "")):
|
||
first = registry.save_link(
|
||
"https://example.com/dup",
|
||
title="Page",
|
||
tags=["tag1", "tag2"],
|
||
)
|
||
second = registry.save_link(
|
||
"https://example.com/dup",
|
||
title="Page Updated",
|
||
tags=["tag2", "tag3"],
|
||
)
|
||
|
||
# Should be the same file
|
||
assert first == second
|
||
|
||
content = first.read_text(encoding="utf-8")
|
||
assert "tag1" in content
|
||
assert "tag2" in content
|
||
assert "tag3" in content
|
||
|
||
def test_duplicate_url_preserves_existing_tags(
|
||
self, registry: LinkRegistry, context_path: Path
|
||
) -> None:
|
||
"""Existing tags are preserved when merging."""
|
||
with patch.object(registry, "_fetch_metadata", return_value=("Page", "")):
|
||
registry.save_link(
|
||
"https://example.com/keep",
|
||
title="Keep",
|
||
tags=["original"],
|
||
)
|
||
result = registry.save_link(
|
||
"https://example.com/keep",
|
||
title="Keep",
|
||
tags=["new"],
|
||
)
|
||
|
||
content = result.read_text(encoding="utf-8")
|
||
assert "original" in content
|
||
assert "new" in content
|
||
|
||
def test_duplicate_url_no_change_if_tags_same(
|
||
self, registry: LinkRegistry, context_path: Path
|
||
) -> None:
|
||
"""No rewrite if tags are already the same."""
|
||
with patch.object(registry, "_fetch_metadata", return_value=("Page", "")):
|
||
first = registry.save_link(
|
||
"https://example.com/same",
|
||
title="Same",
|
||
tags=["a", "b"],
|
||
)
|
||
content_before = first.read_text(encoding="utf-8")
|
||
|
||
second = registry.save_link(
|
||
"https://example.com/same",
|
||
title="Same",
|
||
tags=["a", "b"],
|
||
)
|
||
content_after = second.read_text(encoding="utf-8")
|
||
|
||
assert first == second
|
||
assert content_before == content_after
|
||
|
||
def test_different_urls_create_separate_files(
|
||
self, registry: LinkRegistry, context_path: Path
|
||
) -> None:
|
||
"""Different URLs create separate link artifacts."""
|
||
with patch.object(registry, "_fetch_metadata", return_value=("P", "")):
|
||
first = registry.save_link("https://a.com", title="A")
|
||
second = registry.save_link("https://b.com", title="B")
|
||
|
||
assert first != second
|
||
assert first.exists()
|
||
assert second.exists()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# search_links tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestSearchLinks:
|
||
"""Tests for search_links. Req 8.6."""
|
||
|
||
def _create_link(
|
||
self, registry: LinkRegistry, url: str, title: str, tags: list[str]
|
||
) -> Path:
|
||
"""Helper to create a link artifact."""
|
||
with patch.object(registry, "_fetch_metadata", return_value=("", "")):
|
||
return registry.save_link(url, title=title, tags=tags)
|
||
|
||
def test_search_by_url(self, registry: LinkRegistry) -> None:
|
||
"""Req 8.6: Search finds links matching URL."""
|
||
self._create_link(registry, "https://python.org", "Python", ["lang"])
|
||
self._create_link(registry, "https://rust-lang.org", "Rust", ["lang"])
|
||
|
||
results = registry.search_links("python")
|
||
assert len(results) == 1
|
||
assert results[0].metadata.title == "Python"
|
||
|
||
def test_search_by_title(self, registry: LinkRegistry) -> None:
|
||
"""Req 8.6: Search finds links matching title."""
|
||
self._create_link(registry, "https://a.com", "Django Documentation", ["web"])
|
||
self._create_link(registry, "https://b.com", "Flask Tutorial", ["web"])
|
||
|
||
results = registry.search_links("django")
|
||
assert len(results) == 1
|
||
assert results[0].metadata.title == "Django Documentation"
|
||
|
||
def test_search_by_tag(self, registry: LinkRegistry) -> None:
|
||
"""Req 8.6: Search finds links matching tag."""
|
||
self._create_link(registry, "https://a.com", "A", ["python", "web"])
|
||
self._create_link(registry, "https://b.com", "B", ["rust", "systems"])
|
||
|
||
results = registry.search_links("rust")
|
||
assert len(results) == 1
|
||
assert results[0].metadata.title == "B"
|
||
|
||
def test_search_case_insensitive(self, registry: LinkRegistry) -> None:
|
||
"""Search is case-insensitive."""
|
||
self._create_link(registry, "https://a.com", "Django Docs", ["Python"])
|
||
|
||
results = registry.search_links("DJANGO")
|
||
assert len(results) == 1
|
||
|
||
results = registry.search_links("python")
|
||
assert len(results) == 1
|
||
|
||
def test_search_no_results(self, registry: LinkRegistry) -> None:
|
||
"""Returns empty list when nothing matches."""
|
||
self._create_link(registry, "https://a.com", "Something", ["tag"])
|
||
|
||
results = registry.search_links("nonexistent")
|
||
assert results == []
|
||
|
||
def test_search_empty_links_dir(self, registry: LinkRegistry) -> None:
|
||
"""Returns empty list when links directory doesn't exist."""
|
||
results = registry.search_links("anything")
|
||
assert results == []
|
||
|
||
def test_search_multiple_matches(self, registry: LinkRegistry) -> None:
|
||
"""Returns all matching links."""
|
||
self._create_link(registry, "https://a.com", "Python Docs", ["python"])
|
||
self._create_link(registry, "https://b.com", "Python Tutorial", ["tutorial"])
|
||
|
||
results = registry.search_links("python")
|
||
assert len(results) == 2
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _fetch_metadata tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestFetchMetadata:
|
||
"""Tests for _fetch_metadata."""
|
||
|
||
def test_extracts_title_from_html(self, registry: LinkRegistry) -> None:
|
||
"""Extracts <title> from HTML response."""
|
||
html = b"<html><head><title>Test Page Title</title></head><body></body></html>"
|
||
|
||
with patch("urllib.request.urlopen") as mock_urlopen:
|
||
mock_response = mock_urlopen.return_value.__enter__.return_value
|
||
mock_response.read.return_value = html
|
||
mock_response.headers.get_content_charset.return_value = "utf-8"
|
||
|
||
title, desc = registry._fetch_metadata("https://example.com")
|
||
|
||
assert title == "Test Page Title"
|
||
|
||
def test_extracts_meta_description(self, registry: LinkRegistry) -> None:
|
||
"""Extracts meta description from HTML."""
|
||
html = (
|
||
b'<html><head><title>T</title>'
|
||
b'<meta name="description" content="A page about testing">'
|
||
b"</head></html>"
|
||
)
|
||
|
||
with patch("urllib.request.urlopen") as mock_urlopen:
|
||
mock_response = mock_urlopen.return_value.__enter__.return_value
|
||
mock_response.read.return_value = html
|
||
mock_response.headers.get_content_charset.return_value = "utf-8"
|
||
|
||
title, desc = registry._fetch_metadata("https://example.com")
|
||
|
||
assert desc == "A page about testing"
|
||
|
||
def test_returns_empty_on_network_error(self, registry: LinkRegistry) -> None:
|
||
"""Returns empty strings when fetch fails."""
|
||
with patch("urllib.request.urlopen", side_effect=Exception("Network error")):
|
||
title, desc = registry._fetch_metadata("https://unreachable.test")
|
||
|
||
assert title == ""
|
||
assert desc == ""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _find_existing tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestFindExisting:
|
||
"""Tests for _find_existing."""
|
||
|
||
def test_finds_existing_url(
|
||
self, registry: LinkRegistry, context_path: Path
|
||
) -> None:
|
||
"""Finds an existing link by URL."""
|
||
with patch.object(registry, "_fetch_metadata", return_value=("T", "")):
|
||
saved = registry.save_link("https://example.com/exists", title="T")
|
||
|
||
found = registry._find_existing("https://example.com/exists")
|
||
assert found == saved
|
||
|
||
def test_returns_none_for_unknown_url(
|
||
self, registry: LinkRegistry, context_path: Path
|
||
) -> None:
|
||
"""Returns None when URL doesn't exist."""
|
||
found = registry._find_existing("https://never-saved.com")
|
||
assert found is None
|
||
|
||
def test_returns_none_for_empty_dir(
|
||
self, registry: LinkRegistry, context_path: Path
|
||
) -> None:
|
||
"""Returns None when links dir doesn't exist."""
|
||
found = registry._find_existing("https://example.com")
|
||
assert found is None
|