chore: import upstream snapshot with attribution
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:28:29 +08:00
commit fed8b2eed7
1531 changed files with 1107494 additions and 0 deletions
View File
+185
View File
@@ -0,0 +1,185 @@
from unittest.mock import MagicMock, patch
import pytest
from application.parser.remote.crawler_loader import CrawlerLoader
from application.parser.schema.base import Document
class DummyResponse:
def __init__(self, text: str) -> None:
self.text = text
def raise_for_status(self) -> None:
return None
def _mock_validate_url(url):
"""Mock validate_url that allows test URLs through."""
from urllib.parse import urlparse
if not urlparse(url).scheme:
url = "http://" + url
return url
@patch("application.parser.remote.crawler_loader.validate_url", side_effect=_mock_validate_url)
@patch("application.parser.remote.crawler_loader.pinned_request")
def test_load_data_crawls_same_domain_links(mock_pinned_request, mock_validate_url):
responses = {
"http://example.com": DummyResponse(
"""
<html>
<body>
<a href='/about'>About</a>
<a href='https://external.com/news'>External</a>
</body>
</html>
"""
),
"http://example.com/about": DummyResponse("<html><body>About page</body></html>"),
}
def response_side_effect(_method: str, url: str, timeout=30):
if url not in responses:
raise AssertionError(f"Unexpected request for URL: {url}")
return responses[url]
mock_pinned_request.side_effect = response_side_effect
crawler = CrawlerLoader(limit=5)
result = crawler.load_data("http://example.com")
assert len(result) == 2
assert all(isinstance(doc, Document) for doc in result)
sources = {doc.extra_info.get("source") for doc in result}
assert sources == {"http://example.com", "http://example.com/about"}
paths = {doc.extra_info.get("file_path") for doc in result}
assert paths == {"index.md", "about.md"}
texts = {doc.text for doc in result}
assert texts == {"About\nExternal", "About page"}
assert mock_pinned_request.call_count == 2
@patch("application.parser.remote.crawler_loader.validate_url", side_effect=_mock_validate_url)
@patch("application.parser.remote.crawler_loader.pinned_request")
def test_load_data_accepts_list_input_and_adds_scheme(mock_pinned_request, mock_validate_url):
mock_pinned_request.return_value = DummyResponse("<html><body>No links here</body></html>")
crawler = CrawlerLoader()
result = crawler.load_data(["example.com", "unused.com"])
mock_pinned_request.assert_called_once_with("GET", "http://example.com", timeout=30)
assert len(result) == 1
assert result[0].text == "No links here"
assert result[0].extra_info == {
"source": "http://example.com",
"file_path": "index.md",
}
@patch("application.parser.remote.crawler_loader.validate_url", side_effect=_mock_validate_url)
@patch("application.parser.remote.crawler_loader.pinned_request")
def test_load_data_respects_limit(mock_pinned_request, mock_validate_url):
responses = {
"http://example.com": DummyResponse(
"""
<html>
<body>
<a href='/about'>About</a>
</body>
</html>
"""
),
"http://example.com/about": DummyResponse("<html><body>About</body></html>"),
}
mock_pinned_request.side_effect = lambda _method, url, timeout=30: responses[url]
crawler = CrawlerLoader(limit=1)
result = crawler.load_data("http://example.com")
assert len(result) == 1
assert result[0].text == "About"
assert mock_pinned_request.call_count == 1
@patch("application.parser.remote.crawler_loader.validate_url", side_effect=_mock_validate_url)
@patch("application.parser.remote.crawler_loader.logging")
@patch("application.parser.remote.crawler_loader.pinned_request")
def test_load_data_logs_and_skips_on_request_error(mock_pinned_request, mock_logging, mock_validate_url):
mock_pinned_request.side_effect = Exception("load failure")
crawler = CrawlerLoader()
result = crawler.load_data("http://example.com")
assert result == []
mock_pinned_request.assert_called_once_with("GET", "http://example.com", timeout=30)
mock_logging.error.assert_called_once()
message, = mock_logging.error.call_args.args
assert "Error processing URL http://example.com" in message
assert mock_logging.error.call_args.kwargs.get("exc_info") is True
@patch("application.parser.remote.crawler_loader.validate_url")
def test_load_data_returns_empty_on_ssrf_validation_failure(mock_validate_url):
"""Test that SSRF validation failure returns empty list."""
from application.core.url_validation import SSRFError
mock_validate_url.side_effect = SSRFError("Access to private IP not allowed")
crawler = CrawlerLoader()
result = crawler.load_data("http://192.168.1.1")
assert result == []
mock_validate_url.assert_called_once()
def test_url_to_virtual_path_variants():
crawler = CrawlerLoader()
assert crawler._url_to_virtual_path("https://docs.docsgpt.cloud/") == "index.md"
assert (
crawler._url_to_virtual_path("https://docs.docsgpt.cloud/guides/setup")
== "guides/setup.md"
)
assert (
crawler._url_to_virtual_path("https://docs.docsgpt.cloud/guides/setup/")
== "guides/setup.md"
)
assert crawler._url_to_virtual_path("https://example.com/page.html") == "page.md"
# =====================================================================
# Coverage gap tests (lines 41-43)
# =====================================================================
@pytest.mark.unit
class TestCrawlerLoaderGaps:
def test_pinned_fetch_builds_document_without_webbase_loader(self):
"""The crawler should index the response body it already fetched."""
from application.parser.remote.crawler_loader import CrawlerLoader
loader = CrawlerLoader(limit=5)
with patch(
"application.parser.remote.crawler_loader.validate_url",
return_value="https://example.com",
):
with patch(
"application.parser.remote.crawler_loader.pinned_request"
) as mock_pinned_request:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.text = "<html><body>test</body></html>"
mock_response.raise_for_status.return_value = None
mock_pinned_request.return_value = mock_response
result = loader.load_data("https://example.com")
assert result[0].text == "test"
@@ -0,0 +1,181 @@
from types import SimpleNamespace
from unittest.mock import MagicMock
from urllib.parse import urlparse
import pytest
import requests
from application.parser.remote.crawler_markdown import CrawlerLoader
from application.parser.schema.base import Document
class DummyResponse:
def __init__(self, text):
self.text = text
def raise_for_status(self):
return None
def _fake_extract(value: str) -> SimpleNamespace:
value = value.split("//")[-1]
host = value.split("/")[0]
parts = host.split(".")
if len(parts) >= 2:
domain = parts[-2]
suffix = parts[-1]
else:
domain = host
suffix = ""
return SimpleNamespace(domain=domain, suffix=suffix)
def _mock_validate_url(url):
"""Mock validate_url that allows test URLs through."""
if not urlparse(url).scheme:
url = "http://" + url
return url
@pytest.fixture(autouse=True)
def _patch_validate_url(monkeypatch):
monkeypatch.setattr(
"application.parser.remote.crawler_markdown.validate_url",
_mock_validate_url,
)
@pytest.fixture(autouse=True)
def _patch_tldextract(monkeypatch):
monkeypatch.setattr(
"application.parser.remote.crawler_markdown.tldextract.extract",
_fake_extract,
)
@pytest.fixture(autouse=True)
def _patch_markdownify(monkeypatch):
outputs = {}
def fake_markdownify(html, *_, **__):
return outputs.get(html, html)
monkeypatch.setattr(
"application.parser.remote.crawler_markdown.markdownify",
fake_markdownify,
)
return outputs
def _patch_pinned_request(monkeypatch, side_effect):
"""Replace pinned_request with a stub that maps URL -> response or raises."""
def fake_pinned_request(method, url, **_kwargs):
return side_effect(url)
monkeypatch.setattr(
"application.parser.remote.crawler_markdown.pinned_request",
fake_pinned_request,
)
def test_load_data_filters_external_links(monkeypatch, _patch_markdownify):
root_html = """
<html><head><title>Home</title></head>
<body><a href="/about">About</a><a href="https://other.com">Other</a><p>Welcome</p></body>
</html>
"""
about_html = "<html><head><title>About</title></head><body>About page</body></html>"
_patch_markdownify[root_html] = "Home Markdown"
_patch_markdownify[about_html] = "About Markdown"
responses = {
"http://example.com": DummyResponse(root_html),
"http://example.com/about": DummyResponse(about_html),
}
_patch_pinned_request(monkeypatch, lambda url: responses[url])
loader = CrawlerLoader(limit=5)
docs = loader.load_data("http://example.com")
assert len(docs) == 2
for doc in docs:
assert isinstance(doc, Document)
assert doc.extra_info["source"] in responses
texts = {doc.text for doc in docs}
assert texts == {"Home Markdown", "About Markdown"}
def test_load_data_allows_subdomains(monkeypatch, _patch_markdownify):
root_html = """
<html><head><title>Home</title></head>
<body><a href="http://blog.example.com/post">Blog</a></body>
</html>
"""
blog_html = "<html><head><title>Blog</title></head><body>Blog post</body></html>"
_patch_markdownify[root_html] = "Home Markdown"
_patch_markdownify[blog_html] = "Blog Markdown"
responses = {
"http://example.com": DummyResponse(root_html),
"http://blog.example.com/post": DummyResponse(blog_html),
}
_patch_pinned_request(monkeypatch, lambda url: responses[url])
loader = CrawlerLoader(limit=5, allow_subdomains=True)
docs = loader.load_data("http://example.com")
sources = {doc.extra_info["source"] for doc in docs}
assert "http://blog.example.com/post" in sources
assert len(docs) == 2
def test_load_data_handles_fetch_errors(monkeypatch, _patch_markdownify, _patch_validate_url):
root_html = """
<html><head><title>Home</title></head>
<body><a href="/about">About</a></body>
</html>
"""
_patch_markdownify[root_html] = "Home Markdown"
def side_effect(url):
if url == "http://example.com":
return DummyResponse(root_html)
raise requests.exceptions.RequestException("boom")
_patch_pinned_request(monkeypatch, side_effect)
loader = CrawlerLoader(limit=5)
mock_print = MagicMock()
monkeypatch.setattr("builtins.print", mock_print)
docs = loader.load_data("http://example.com")
assert len(docs) == 1
assert docs[0].text == "Home Markdown"
assert mock_print.called
def test_load_data_returns_empty_on_ssrf_validation_failure(monkeypatch):
"""Test that SSRF validation failure returns empty list."""
from application.core.url_validation import SSRFError
def raise_ssrf_error(url):
raise SSRFError("Access to private IP not allowed")
monkeypatch.setattr(
"application.parser.remote.crawler_markdown.validate_url",
raise_ssrf_error,
)
loader = CrawlerLoader()
result = loader.load_data("http://192.168.1.1")
assert result == []
+314
View File
@@ -0,0 +1,314 @@
import base64
import pytest
from unittest.mock import patch, MagicMock
import requests
from application.parser.remote.github_loader import GitHubLoader
def make_response(json_data=None, status_code=200, raise_error=None):
resp = MagicMock()
resp.status_code = status_code
resp.json.return_value = json_data
if raise_error is not None:
resp.raise_for_status.side_effect = raise_error
else:
resp.raise_for_status.return_value = None
return resp
class TestGitHubLoaderFetchFileContent:
@patch("application.parser.remote.github_loader.requests.get")
def test_text_file_base64_decoded(self, mock_get):
loader = GitHubLoader()
content_str = "Hello from README"
b64 = base64.b64encode(content_str.encode("utf-8")).decode("utf-8")
mock_get.return_value = make_response({"encoding": "base64", "content": b64})
result = loader.fetch_file_content("owner/repo", "README.md")
assert result == content_str
mock_get.assert_called_once_with(
"https://api.github.com/repos/owner/repo/contents/README.md",
headers=loader.headers,
timeout=100,
)
@patch("application.parser.remote.github_loader.requests.get")
def test_binary_file_skipped(self, mock_get):
loader = GitHubLoader()
mock_get.return_value = make_response({"encoding": "base64", "content": "AAAA"})
result = loader.fetch_file_content("owner/repo", "image.png")
assert result is None
@patch("application.parser.remote.github_loader.requests.get")
def test_non_base64_plain_content(self, mock_get):
loader = GitHubLoader()
mock_get.return_value = make_response({"encoding": "", "content": "Plain text"})
result = loader.fetch_file_content("owner/repo", "file.txt")
assert result == "Plain text"
@patch("application.parser.remote.github_loader.requests.get")
def test_http_error_raises(self, mock_get):
loader = GitHubLoader()
http_err = requests.HTTPError("Not found")
mock_get.return_value = make_response(status_code=404, raise_error=http_err)
with pytest.raises(requests.HTTPError):
loader.fetch_file_content("owner/repo", "missing.txt")
class TestGitHubLoaderFetchRepoFiles:
@patch("application.parser.remote.github_loader.requests.get")
def test_recurses_directories(self, mock_get):
loader = GitHubLoader()
def side_effect(url, headers=None, timeout=None):
if url.endswith("/contents/"):
return make_response([
{"type": "file", "path": "README.md"},
{"type": "dir", "path": "src"},
])
elif url.endswith("/contents/src"):
return make_response([
{"type": "file", "path": "src/main.py"},
{"type": "file", "path": "src/util.py"},
])
raise AssertionError(f"Unexpected URL: {url}")
mock_get.side_effect = side_effect
files = loader.fetch_repo_files("owner/repo", path="")
assert set(files) == {"README.md", "src/main.py", "src/util.py"}
class TestGitHubLoaderLoadData:
def test_load_data_builds_documents_from_files(self, monkeypatch):
loader = GitHubLoader()
# Stub out network-dependent methods
monkeypatch.setattr(loader, "fetch_repo_files", lambda repo, path="": [
"README.md", "src/main.py"
])
def fake_fetch_content(repo, file_path):
return f"content for {file_path}"
monkeypatch.setattr(loader, "fetch_file_content", fake_fetch_content)
docs = loader.load_data("https://github.com/owner/repo")
assert len(docs) == 2
assert docs[0].text == "content for README.md"
assert docs[0].extra_info == {
"title": "README.md",
"source": "https://github.com/owner/repo/blob/main/README.md",
}
assert docs[1].text == "content for src/main.py"
assert docs[1].extra_info == {
"title": "src/main.py",
"source": "https://github.com/owner/repo/blob/main/src/main.py",
}
class TestGitHubLoaderIsTextFile:
def test_known_extension(self):
loader = GitHubLoader()
assert loader.is_text_file("app.py") is True
assert loader.is_text_file("data.json") is True
def test_unknown_extension_with_text_mime(self):
loader = GitHubLoader()
assert loader.is_text_file("file.xml") is True
def test_binary_file(self):
loader = GitHubLoader()
assert loader.is_text_file("image.png") is False
@patch("application.parser.remote.github_loader.mimetypes.guess_type")
def test_mime_fallback_text(self, mock_mime):
mock_mime.return_value = ("text/plain", None)
loader = GitHubLoader()
assert loader.is_text_file("unknownfile.xyz") is True
class TestGitHubLoaderMakeRequest:
@patch("application.parser.remote.github_loader.requests.get")
def test_success(self, mock_get):
loader = GitHubLoader()
mock_get.return_value = make_response({"ok": True}, 200)
resp = loader._make_request("http://example.com")
assert resp.status_code == 200
@patch("application.parser.remote.github_loader.time.sleep")
@patch("application.parser.remote.github_loader.requests.get")
def test_rate_limit_retry(self, mock_get, mock_sleep):
loader = GitHubLoader()
rate_resp = MagicMock()
rate_resp.status_code = 403
rate_resp.json.return_value = {"message": "API rate limit exceeded"}
rate_resp.headers = {
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": "9999999",
}
ok_resp = make_response({"ok": True}, 200)
mock_get.side_effect = [rate_resp, ok_resp]
resp = loader._make_request("http://example.com", max_retries=2)
assert resp.status_code == 200
mock_sleep.assert_called_once()
@patch("application.parser.remote.github_loader.requests.get")
def test_rate_limit_exhausted(self, mock_get):
loader = GitHubLoader()
rate_resp = MagicMock()
rate_resp.status_code = 403
rate_resp.json.return_value = {"message": "API rate limit exceeded"}
rate_resp.headers = {
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": "9999",
}
mock_get.return_value = rate_resp
with pytest.raises(Exception, match="rate limit exceeded"):
loader._make_request("http://example.com", max_retries=1)
@patch("application.parser.remote.github_loader.requests.get")
def test_403_non_rate_limit(self, mock_get):
loader = GitHubLoader()
resp = MagicMock()
resp.status_code = 403
resp.json.return_value = {"message": "Forbidden - need auth"}
resp.headers = {"X-RateLimit-Remaining": "50", "X-RateLimit-Reset": "9999"}
mock_get.return_value = resp
with pytest.raises(Exception, match="GitHub API error"):
loader._make_request("http://example.com", max_retries=1)
@patch("application.parser.remote.github_loader.requests.get")
def test_other_error_raises(self, mock_get):
loader = GitHubLoader()
resp = make_response(
status_code=500,
raise_error=requests.HTTPError("Server Error"),
)
mock_get.return_value = resp
with pytest.raises(requests.HTTPError):
loader._make_request("http://example.com", max_retries=1)
class TestGitHubLoaderFetchRepoFilesErrors:
@patch("application.parser.remote.github_loader.requests.get")
def test_api_error_message_in_dict(self, mock_get):
loader = GitHubLoader()
mock_get.return_value = make_response(
{"message": "Not Found"}, 200
)
with pytest.raises(Exception, match="GitHub API error"):
loader.fetch_repo_files("owner/repo")
@patch("application.parser.remote.github_loader.requests.get")
def test_non_list_response(self, mock_get):
loader = GitHubLoader()
mock_get.return_value = make_response("not a list", 200)
with pytest.raises(TypeError, match="Expected list"):
loader.fetch_repo_files("owner/repo")
class TestGitHubLoaderFetchFileContentEdgeCases:
@patch("application.parser.remote.github_loader.requests.get")
def test_empty_base64_text_returns_none(self, mock_get):
loader = GitHubLoader()
b64 = base64.b64encode(b"").decode("utf-8")
mock_get.return_value = make_response(
{"encoding": "base64", "content": b64}
)
result = loader.fetch_file_content("owner/repo", "empty.py")
assert result is None
@patch("application.parser.remote.github_loader.requests.get")
def test_empty_non_base64_returns_none(self, mock_get):
loader = GitHubLoader()
mock_get.return_value = make_response(
{"encoding": "none", "content": " "}
)
result = loader.fetch_file_content("owner/repo", "empty.txt")
assert result is None
@patch("application.parser.remote.github_loader.requests.get")
def test_decode_failure_returns_none(self, mock_get):
loader = GitHubLoader()
mock_get.return_value = make_response(
{"encoding": "base64", "content": "invalid!!base64"}
)
result = loader.fetch_file_content("owner/repo", "broken.py")
assert result is None
class TestGitHubLoaderLoadDataSkipsNone:
def test_skips_binary_files(self, monkeypatch):
loader = GitHubLoader()
monkeypatch.setattr(
loader, "fetch_repo_files", lambda repo, path="": ["a.py", "b.png"]
)
def fake_content(repo, fp):
return "code" if fp == "a.py" else None
monkeypatch.setattr(loader, "fetch_file_content", fake_content)
docs = loader.load_data("https://github.com/o/r")
assert len(docs) == 1
assert docs[0].doc_id == "a.py"
class TestGitHubLoaderRobustness:
@patch("application.parser.remote.github_loader.requests.get")
def test_fetch_repo_files_non_json_raises(self, mock_get):
resp = MagicMock()
resp.json.side_effect = ValueError("No JSON")
mock_get.return_value = resp
with pytest.raises(ValueError):
GitHubLoader().fetch_repo_files("owner/repo")
@patch("application.parser.remote.github_loader.requests.get")
def test_fetch_repo_files_unexpected_shape_missing_type_raises(self, mock_get):
# Missing 'type' in items should raise KeyError when accessed
mock_get.return_value = make_response([{"path": "README.md"}])
with pytest.raises(KeyError):
GitHubLoader().fetch_repo_files("owner/repo")
@patch("application.parser.remote.github_loader.requests.get")
def test_fetch_file_content_non_json_raises(self, mock_get):
resp = MagicMock()
resp.status_code = 200
resp.json.side_effect = ValueError("No JSON")
mock_get.return_value = resp
with pytest.raises(ValueError):
GitHubLoader().fetch_file_content("owner/repo", "README.md")
@patch("application.parser.remote.github_loader.requests.get")
def test_fetch_file_content_unexpected_shape_missing_content_returns_none(self, mock_get):
# encoding indicates base64 text, but 'content' key is missing
# With the new code, the exception is caught and returns None (treated as binary/skipped)
resp = make_response({"encoding": "base64"})
mock_get.return_value = resp
result = GitHubLoader().fetch_file_content("owner/repo", "file.txt")
assert result is None
@patch("application.parser.remote.github_loader.base64.b64decode")
@patch("application.parser.remote.github_loader.requests.get")
def test_large_binary_skip_does_not_decode(self, mock_get, mock_b64decode):
# Ensure we don't attempt to decode large binary content for non-text files
mock_b64decode.side_effect = AssertionError("b64decode should not be called for binary files")
mock_get.return_value = make_response({"encoding": "base64", "content": "AAA"})
result = GitHubLoader().fetch_file_content("owner/repo", "bigfile.bin")
assert result is None
+83
View File
@@ -0,0 +1,83 @@
import json
from unittest.mock import patch, MagicMock
import pytest
from application.parser.remote.reddit_loader import RedditPostsLoaderRemote
class TestRedditPostsLoaderRemote:
def test_invalid_json_raises(self):
loader = RedditPostsLoaderRemote()
with pytest.raises(ValueError) as exc:
loader.load_data("not a json")
assert "Invalid JSON input" in str(exc.value)
def test_missing_required_fields_raises(self):
loader = RedditPostsLoaderRemote()
payload = json.dumps({"client_id": "id"})
with pytest.raises(ValueError) as exc:
loader.load_data(payload)
assert "Missing required fields" in str(exc.value)
assert "client_secret" in str(exc.value)
@patch("application.parser.remote.reddit_loader.RedditPostsLoader")
def test_constructs_loader_and_loads_with_defaults(self, MockRedditLoader):
loader = RedditPostsLoaderRemote()
instance = MagicMock()
docs = [MagicMock(), MagicMock()]
instance.load.return_value = docs
MockRedditLoader.return_value = instance
payload = {
"client_id": "cid",
"client_secret": "csecret",
"user_agent": "ua",
"search_queries": ["r/langchain"],
}
result = loader.load_data(json.dumps(payload))
MockRedditLoader.assert_called_once_with(
client_id="cid",
client_secret="csecret",
user_agent="ua",
categories=["new", "hot"],
mode="subreddit",
search_queries=["r/langchain"],
number_posts=10,
)
instance.load.assert_called_once()
assert result == docs
@patch("application.parser.remote.reddit_loader.RedditPostsLoader")
def test_constructs_loader_and_loads_with_overrides(self, MockRedditLoader):
loader = RedditPostsLoaderRemote()
instance = MagicMock()
instance.load.return_value = []
MockRedditLoader.return_value = instance
payload = {
"client_id": "cid",
"client_secret": "csecret",
"user_agent": "ua",
"search_queries": ["python"],
"categories": ["hot"],
"mode": "comments",
"number_posts": 3,
}
loader.load_data(json.dumps(payload))
MockRedditLoader.assert_called_once_with(
client_id="cid",
client_secret="csecret",
user_agent="ua",
categories=["hot"],
mode="comments",
search_queries=["python"],
number_posts=3,
)
instance.load.assert_called_once()
+131
View File
@@ -0,0 +1,131 @@
"""Tests for application.parser.remote.remote_creator."""
import json
import pytest
from unittest.mock import MagicMock
@pytest.mark.unit
class TestRemoteCreator:
def test_create_loader_valid_type(self):
"""Cover line 34: returns loader instance for valid type."""
from application.parser.remote.remote_creator import RemoteCreator
mock_loader_cls = MagicMock()
original_loaders = RemoteCreator.loaders.copy()
RemoteCreator.loaders["url"] = mock_loader_cls
try:
RemoteCreator.create_loader("url")
mock_loader_cls.assert_called_once()
finally:
RemoteCreator.loaders = original_loaders
def test_create_loader_invalid_type_raises(self):
"""Cover lines 32-33: raises ValueError for unknown type."""
from application.parser.remote.remote_creator import RemoteCreator
with pytest.raises(ValueError, match="No loader class found"):
RemoteCreator.create_loader("nonexistent_xyz")
def test_create_loader_case_insensitive(self):
"""Cover line 31: type.lower() normalization."""
from application.parser.remote.remote_creator import RemoteCreator
mock_loader_cls = MagicMock()
original_loaders = RemoteCreator.loaders.copy()
RemoteCreator.loaders["sitemap"] = mock_loader_cls
try:
RemoteCreator.create_loader("SITEMAP")
mock_loader_cls.assert_called_once()
finally:
RemoteCreator.loaders = original_loaders
@pytest.mark.unit
class TestNormalizeRemoteData:
"""``normalize_remote_data`` maps a stored JSONB ``remote_data`` value
back to the ``source_data`` shape each loader expects."""
def test_none_passes_through(self):
from application.parser.remote.remote_creator import normalize_remote_data
assert normalize_remote_data("crawler", None) is None
def test_crawler_dict_with_url_key(self):
from application.parser.remote.remote_creator import normalize_remote_data
result = normalize_remote_data(
"crawler", {"url": "https://example.com", "provider": "crawler"}
)
assert result == "https://example.com"
def test_url_dict_with_url_key(self):
from application.parser.remote.remote_creator import normalize_remote_data
result = normalize_remote_data("url", {"url": "https://example.com"})
assert result == "https://example.com"
def test_url_legacy_raw_key(self):
"""Legacy rows wrapped a bare URL string as ``{"raw": ...}``."""
from application.parser.remote.remote_creator import normalize_remote_data
result = normalize_remote_data("crawler", {"raw": "https://legacy.example.com"})
assert result == "https://legacy.example.com"
def test_url_dict_with_urls_list(self):
from application.parser.remote.remote_creator import normalize_remote_data
result = normalize_remote_data(
"url", {"urls": ["https://a.example.com", "https://b.example.com"]}
)
assert result == ["https://a.example.com", "https://b.example.com"]
def test_github_repo_url_key(self):
from application.parser.remote.remote_creator import normalize_remote_data
result = normalize_remote_data(
"github", {"repo_url": "https://github.com/arc53/DocsGPT"}
)
assert result == "https://github.com/arc53/DocsGPT"
def test_sitemap_dict_with_url_key(self):
from application.parser.remote.remote_creator import normalize_remote_data
result = normalize_remote_data("sitemap", {"url": "https://example.com/sitemap.xml"})
assert result == "https://example.com/sitemap.xml"
def test_plain_string_url_passes_through(self):
from application.parser.remote.remote_creator import normalize_remote_data
assert normalize_remote_data("crawler", "https://example.com") == "https://example.com"
def test_url_dict_without_url_key_returns_none(self):
"""A URL-type loader must never receive a dict, even a malformed one."""
from application.parser.remote.remote_creator import normalize_remote_data
assert normalize_remote_data("crawler", {"provider": "crawler"}) is None
def test_reddit_dict_serialized_to_json_string(self):
"""reddit's loader runs json.loads() — it needs a JSON string."""
from application.parser.remote.remote_creator import normalize_remote_data
result = normalize_remote_data(
"reddit", {"client_id": "x", "search_queries": ["y"]}
)
assert isinstance(result, str)
assert json.loads(result) == {"client_id": "x", "search_queries": ["y"]}
def test_s3_dict_passes_through(self):
"""S3Loader.load_data() accepts a dict, so it is left untouched."""
from application.parser.remote.remote_creator import normalize_remote_data
data = {"bucket": "b", "prefix": "k"}
assert normalize_remote_data("s3", data) == data
def test_json_string_remote_data_is_parsed(self):
"""Legacy rows that stored the JSON itself as a string still resolve."""
from application.parser.remote.remote_creator import normalize_remote_data
result = normalize_remote_data("crawler", '{"url": "https://example.com"}')
assert result == "https://example.com"
+905
View File
@@ -0,0 +1,905 @@
"""Tests for S3 loader implementation."""
import json
import pytest
from unittest.mock import MagicMock, patch
from botocore.exceptions import ClientError, NoCredentialsError
@pytest.fixture
def mock_boto3():
"""Mock boto3 module."""
with patch.dict("sys.modules", {"boto3": MagicMock()}):
with patch("application.parser.remote.s3_loader.boto3") as mock:
yield mock
@pytest.fixture
def s3_loader(mock_boto3):
"""Create S3Loader instance with mocked boto3."""
from application.parser.remote.s3_loader import S3Loader
loader = S3Loader()
return loader
class TestS3LoaderInit:
"""Test S3Loader initialization."""
def test_init_raises_import_error_when_boto3_missing(self):
"""Should raise ImportError when boto3 is not installed."""
with patch("application.parser.remote.s3_loader.boto3", None):
from application.parser.remote.s3_loader import S3Loader
with pytest.raises(ImportError, match="boto3 is required"):
S3Loader()
def test_init_sets_client_to_none(self, mock_boto3):
"""Should initialize with s3_client as None."""
from application.parser.remote.s3_loader import S3Loader
loader = S3Loader()
assert loader.s3_client is None
class TestNormalizeEndpointUrl:
"""Test endpoint URL normalization for S3-compatible services."""
def test_returns_unchanged_for_empty_endpoint(self, s3_loader):
"""Should return unchanged values when endpoint_url is empty."""
endpoint, bucket = s3_loader._normalize_endpoint_url("", "my-bucket")
assert endpoint == ""
assert bucket == "my-bucket"
def test_returns_unchanged_for_none_endpoint(self, s3_loader):
"""Should return unchanged values when endpoint_url is None."""
endpoint, bucket = s3_loader._normalize_endpoint_url(None, "my-bucket")
assert endpoint is None
assert bucket == "my-bucket"
def test_extracts_bucket_from_do_spaces_url(self, s3_loader):
"""Should extract bucket name from DigitalOcean Spaces bucket-prefixed URL."""
endpoint, bucket = s3_loader._normalize_endpoint_url(
"https://mybucket.nyc3.digitaloceanspaces.com", ""
)
assert endpoint == "https://nyc3.digitaloceanspaces.com"
assert bucket == "mybucket"
def test_extracts_bucket_overrides_provided_bucket(self, s3_loader):
"""Should use extracted bucket when it differs from provided one."""
endpoint, bucket = s3_loader._normalize_endpoint_url(
"https://mybucket.lon1.digitaloceanspaces.com", "other-bucket"
)
assert endpoint == "https://lon1.digitaloceanspaces.com"
assert bucket == "mybucket"
def test_keeps_provided_bucket_when_matches_extracted(self, s3_loader):
"""Should keep bucket when provided matches extracted."""
endpoint, bucket = s3_loader._normalize_endpoint_url(
"https://mybucket.sfo3.digitaloceanspaces.com", "mybucket"
)
assert endpoint == "https://sfo3.digitaloceanspaces.com"
assert bucket == "mybucket"
def test_returns_unchanged_for_standard_do_endpoint(self, s3_loader):
"""Should return unchanged for standard DO Spaces endpoint."""
endpoint, bucket = s3_loader._normalize_endpoint_url(
"https://nyc3.digitaloceanspaces.com", "my-bucket"
)
assert endpoint == "https://nyc3.digitaloceanspaces.com"
assert bucket == "my-bucket"
def test_returns_unchanged_for_aws_endpoint(self, s3_loader):
"""Should return unchanged for standard AWS S3 endpoints."""
endpoint, bucket = s3_loader._normalize_endpoint_url(
"https://s3.us-east-1.amazonaws.com", "my-bucket"
)
assert endpoint == "https://s3.us-east-1.amazonaws.com"
assert bucket == "my-bucket"
def test_handles_minio_endpoint(self, s3_loader):
"""Should return unchanged for MinIO endpoints."""
endpoint, bucket = s3_loader._normalize_endpoint_url(
"http://localhost:9000", "my-bucket"
)
assert endpoint == "http://localhost:9000"
assert bucket == "my-bucket"
class TestInitClient:
"""Test S3 client initialization."""
def test_init_client_creates_boto3_client(self, s3_loader, mock_boto3):
"""Should create boto3 S3 client with provided credentials."""
s3_loader._init_client(
aws_access_key_id="test-key",
aws_secret_access_key="test-secret",
region_name="us-west-2",
)
mock_boto3.client.assert_called_once()
call_kwargs = mock_boto3.client.call_args[1]
assert call_kwargs["aws_access_key_id"] == "test-key"
assert call_kwargs["aws_secret_access_key"] == "test-secret"
assert call_kwargs["region_name"] == "us-west-2"
def test_init_client_with_custom_endpoint(self, s3_loader, mock_boto3):
"""Should configure path-style addressing for custom endpoints."""
with patch(
"application.parser.remote.s3_loader.validate_url",
side_effect=lambda u: u,
):
s3_loader._init_client(
aws_access_key_id="test-key",
aws_secret_access_key="test-secret",
region_name="us-east-1",
endpoint_url="https://nyc3.digitaloceanspaces.com",
bucket="my-bucket",
)
call_kwargs = mock_boto3.client.call_args[1]
assert call_kwargs["endpoint_url"] == "https://nyc3.digitaloceanspaces.com"
assert "config" in call_kwargs
def test_init_client_normalizes_do_endpoint(self, s3_loader, mock_boto3):
"""Should normalize DigitalOcean Spaces bucket-prefixed URLs."""
with patch(
"application.parser.remote.s3_loader.validate_url",
side_effect=lambda u: u,
):
corrected_bucket = s3_loader._init_client(
aws_access_key_id="test-key",
aws_secret_access_key="test-secret",
region_name="us-east-1",
endpoint_url="https://mybucket.nyc3.digitaloceanspaces.com",
bucket="",
)
assert corrected_bucket == "mybucket"
call_kwargs = mock_boto3.client.call_args[1]
assert call_kwargs["endpoint_url"] == "https://nyc3.digitaloceanspaces.com"
def test_init_client_returns_bucket_name(self, s3_loader, mock_boto3):
"""Should return the bucket name (potentially corrected)."""
result = s3_loader._init_client(
aws_access_key_id="test-key",
aws_secret_access_key="test-secret",
region_name="us-east-1",
bucket="my-bucket",
)
assert result == "my-bucket"
class TestIsTextFile:
"""Test text file detection."""
def test_recognizes_common_text_extensions(self, s3_loader):
"""Should recognize common text file extensions."""
text_files = [
"readme.txt",
"docs.md",
"config.json",
"data.yaml",
"script.py",
"app.js",
"main.go",
"style.css",
"index.html",
]
for filename in text_files:
assert s3_loader.is_text_file(filename), f"{filename} should be text"
def test_rejects_binary_extensions(self, s3_loader):
"""Should reject binary file extensions."""
binary_files = ["image.png", "photo.jpg", "archive.zip", "app.exe", "doc.pdf"]
for filename in binary_files:
assert not s3_loader.is_text_file(filename), f"{filename} should not be text"
def test_case_insensitive_matching(self, s3_loader):
"""Should match extensions case-insensitively."""
assert s3_loader.is_text_file("README.TXT")
assert s3_loader.is_text_file("Config.JSON")
assert s3_loader.is_text_file("Script.PY")
class TestIsSupportedDocument:
"""Test document file detection."""
def test_recognizes_document_extensions(self, s3_loader):
"""Should recognize document file extensions."""
doc_files = [
"report.pdf",
"document.docx",
"spreadsheet.xlsx",
"presentation.pptx",
"book.epub",
]
for filename in doc_files:
assert s3_loader.is_supported_document(
filename
), f"{filename} should be document"
def test_rejects_non_document_extensions(self, s3_loader):
"""Should reject non-document file extensions."""
non_doc_files = ["image.png", "script.py", "readme.txt", "archive.zip"]
for filename in non_doc_files:
assert not s3_loader.is_supported_document(
filename
), f"{filename} should not be document"
def test_case_insensitive_matching(self, s3_loader):
"""Should match extensions case-insensitively."""
assert s3_loader.is_supported_document("Report.PDF")
assert s3_loader.is_supported_document("Document.DOCX")
class TestListObjects:
"""Test S3 object listing."""
def test_list_objects_returns_file_keys(self, s3_loader, mock_boto3):
"""Should return list of file keys from bucket."""
mock_client = MagicMock()
s3_loader.s3_client = mock_client
paginator = MagicMock()
mock_client.get_paginator.return_value = paginator
paginator.paginate.return_value = [
{
"Contents": [
{"Key": "file1.txt"},
{"Key": "file2.md"},
{"Key": "folder/"}, # Directory marker, should be skipped
{"Key": "folder/file3.py"},
]
}
]
result = s3_loader.list_objects("test-bucket", "")
assert result == ["file1.txt", "file2.md", "folder/file3.py"]
mock_client.get_paginator.assert_called_once_with("list_objects_v2")
paginator.paginate.assert_called_once_with(Bucket="test-bucket", Prefix="")
def test_list_objects_with_prefix(self, s3_loader):
"""Should filter objects by prefix."""
mock_client = MagicMock()
s3_loader.s3_client = mock_client
paginator = MagicMock()
mock_client.get_paginator.return_value = paginator
paginator.paginate.return_value = [
{"Contents": [{"Key": "docs/readme.md"}, {"Key": "docs/guide.txt"}]}
]
result = s3_loader.list_objects("test-bucket", "docs/")
paginator.paginate.assert_called_once_with(Bucket="test-bucket", Prefix="docs/")
assert len(result) == 2
def test_list_objects_handles_empty_bucket(self, s3_loader):
"""Should return empty list for empty bucket."""
mock_client = MagicMock()
s3_loader.s3_client = mock_client
paginator = MagicMock()
mock_client.get_paginator.return_value = paginator
paginator.paginate.return_value = [{}] # No Contents key
result = s3_loader.list_objects("test-bucket", "")
assert result == []
def test_list_objects_raises_on_no_such_bucket(self, s3_loader):
"""Should raise exception when bucket doesn't exist."""
mock_client = MagicMock()
s3_loader.s3_client = mock_client
paginator = MagicMock()
mock_client.get_paginator.return_value = paginator
paginator.paginate.return_value.__iter__ = MagicMock(
side_effect=ClientError(
{"Error": {"Code": "NoSuchBucket", "Message": "Bucket not found"}},
"ListObjectsV2",
)
)
with pytest.raises(Exception, match="does not exist"):
s3_loader.list_objects("nonexistent-bucket", "")
def test_list_objects_raises_on_access_denied(self, s3_loader):
"""Should raise exception on access denied."""
mock_client = MagicMock()
s3_loader.s3_client = mock_client
paginator = MagicMock()
mock_client.get_paginator.return_value = paginator
paginator.paginate.return_value.__iter__ = MagicMock(
side_effect=ClientError(
{"Error": {"Code": "AccessDenied", "Message": "Access denied"}},
"ListObjectsV2",
)
)
with pytest.raises(Exception, match="Access denied"):
s3_loader.list_objects("test-bucket", "")
def test_list_objects_raises_on_no_credentials(self, s3_loader):
"""Should raise exception when credentials are missing."""
mock_client = MagicMock()
s3_loader.s3_client = mock_client
paginator = MagicMock()
mock_client.get_paginator.return_value = paginator
paginator.paginate.return_value.__iter__ = MagicMock(
side_effect=NoCredentialsError()
)
with pytest.raises(Exception, match="credentials not found"):
s3_loader.list_objects("test-bucket", "")
class TestGetObjectContent:
"""Test S3 object content retrieval."""
def test_get_text_file_content(self, s3_loader):
"""Should return decoded text content for text files."""
mock_client = MagicMock()
s3_loader.s3_client = mock_client
mock_body = MagicMock()
mock_body.read.return_value = b"Hello, World!"
mock_client.get_object.return_value = {"Body": mock_body}
result = s3_loader.get_object_content("test-bucket", "readme.txt")
assert result == "Hello, World!"
mock_client.get_object.assert_called_once_with(
Bucket="test-bucket", Key="readme.txt"
)
def test_skip_unsupported_file_types(self, s3_loader):
"""Should return None for unsupported file types."""
mock_client = MagicMock()
s3_loader.s3_client = mock_client
result = s3_loader.get_object_content("test-bucket", "image.png")
assert result is None
mock_client.get_object.assert_not_called()
def test_skip_empty_text_files(self, s3_loader):
"""Should return None for empty text files."""
mock_client = MagicMock()
s3_loader.s3_client = mock_client
mock_body = MagicMock()
mock_body.read.return_value = b" \n\t "
mock_client.get_object.return_value = {"Body": mock_body}
result = s3_loader.get_object_content("test-bucket", "empty.txt")
assert result is None
def test_returns_none_on_unicode_decode_error(self, s3_loader):
"""Should return None when text file can't be decoded."""
mock_client = MagicMock()
s3_loader.s3_client = mock_client
mock_body = MagicMock()
mock_body.read.return_value = b"\xff\xfe" # Invalid UTF-8
mock_client.get_object.return_value = {"Body": mock_body}
result = s3_loader.get_object_content("test-bucket", "binary.txt")
assert result is None
def test_returns_none_on_no_such_key(self, s3_loader):
"""Should return None when object doesn't exist."""
mock_client = MagicMock()
s3_loader.s3_client = mock_client
mock_client.get_object.side_effect = ClientError(
{"Error": {"Code": "NoSuchKey", "Message": "Key not found"}},
"GetObject",
)
result = s3_loader.get_object_content("test-bucket", "missing.txt")
assert result is None
def test_returns_none_on_access_denied(self, s3_loader):
"""Should return None when access is denied."""
mock_client = MagicMock()
s3_loader.s3_client = mock_client
mock_client.get_object.side_effect = ClientError(
{"Error": {"Code": "AccessDenied", "Message": "Access denied"}},
"GetObject",
)
result = s3_loader.get_object_content("test-bucket", "secret.txt")
assert result is None
def test_processes_document_files(self, s3_loader):
"""Should process document files through parser."""
mock_client = MagicMock()
s3_loader.s3_client = mock_client
mock_body = MagicMock()
mock_body.read.return_value = b"PDF content"
mock_client.get_object.return_value = {"Body": mock_body}
with patch.object(
s3_loader, "_process_document", return_value="Extracted text"
) as mock_process:
result = s3_loader.get_object_content("test-bucket", "document.pdf")
assert result == "Extracted text"
mock_process.assert_called_once_with(b"PDF content", "document.pdf")
class TestLoadData:
"""Test main load_data method."""
def test_load_data_from_dict_input(self, s3_loader, mock_boto3):
"""Should load documents from dict input."""
mock_client = MagicMock()
mock_boto3.client.return_value = mock_client
# Setup mock paginator
paginator = MagicMock()
mock_client.get_paginator.return_value = paginator
paginator.paginate.return_value = [
{"Contents": [{"Key": "readme.md"}, {"Key": "guide.txt"}]}
]
# Setup mock get_object
def get_object_side_effect(Bucket, Key):
mock_body = MagicMock()
mock_body.read.return_value = f"Content of {Key}".encode()
return {"Body": mock_body}
mock_client.get_object.side_effect = get_object_side_effect
input_data = {
"aws_access_key_id": "test-key",
"aws_secret_access_key": "test-secret",
"bucket": "test-bucket",
}
docs = s3_loader.load_data(input_data)
assert len(docs) == 2
assert docs[0].text == "Content of readme.md"
assert docs[0].extra_info["bucket"] == "test-bucket"
assert docs[0].extra_info["key"] == "readme.md"
assert docs[0].extra_info["source"] == "s3://test-bucket/readme.md"
def test_load_data_from_json_string(self, s3_loader, mock_boto3):
"""Should load documents from JSON string input."""
mock_client = MagicMock()
mock_boto3.client.return_value = mock_client
paginator = MagicMock()
mock_client.get_paginator.return_value = paginator
paginator.paginate.return_value = [{"Contents": [{"Key": "file.txt"}]}]
mock_body = MagicMock()
mock_body.read.return_value = b"File content"
mock_client.get_object.return_value = {"Body": mock_body}
input_json = json.dumps(
{
"aws_access_key_id": "test-key",
"aws_secret_access_key": "test-secret",
"bucket": "test-bucket",
}
)
docs = s3_loader.load_data(input_json)
assert len(docs) == 1
assert docs[0].text == "File content"
def test_load_data_with_prefix(self, s3_loader, mock_boto3):
"""Should filter objects by prefix."""
mock_client = MagicMock()
mock_boto3.client.return_value = mock_client
paginator = MagicMock()
mock_client.get_paginator.return_value = paginator
paginator.paginate.return_value = [{"Contents": [{"Key": "docs/readme.md"}]}]
mock_body = MagicMock()
mock_body.read.return_value = b"Documentation"
mock_client.get_object.return_value = {"Body": mock_body}
input_data = {
"aws_access_key_id": "test-key",
"aws_secret_access_key": "test-secret",
"bucket": "test-bucket",
"prefix": "docs/",
}
s3_loader.load_data(input_data)
paginator.paginate.assert_called_once_with(Bucket="test-bucket", Prefix="docs/")
def test_load_data_with_custom_region(self, s3_loader, mock_boto3):
"""Should use custom region."""
mock_client = MagicMock()
mock_boto3.client.return_value = mock_client
paginator = MagicMock()
mock_client.get_paginator.return_value = paginator
paginator.paginate.return_value = [{}]
input_data = {
"aws_access_key_id": "test-key",
"aws_secret_access_key": "test-secret",
"bucket": "test-bucket",
"region": "eu-west-1",
}
s3_loader.load_data(input_data)
call_kwargs = mock_boto3.client.call_args[1]
assert call_kwargs["region_name"] == "eu-west-1"
def test_load_data_with_custom_endpoint(self, s3_loader, mock_boto3):
"""Should use custom endpoint URL."""
mock_client = MagicMock()
mock_boto3.client.return_value = mock_client
paginator = MagicMock()
mock_client.get_paginator.return_value = paginator
paginator.paginate.return_value = [{}]
input_data = {
"aws_access_key_id": "test-key",
"aws_secret_access_key": "test-secret",
"bucket": "test-bucket",
"endpoint_url": "https://nyc3.digitaloceanspaces.com",
}
with patch(
"application.parser.remote.s3_loader.validate_url",
side_effect=lambda u: u,
):
s3_loader.load_data(input_data)
call_kwargs = mock_boto3.client.call_args[1]
assert call_kwargs["endpoint_url"] == "https://nyc3.digitaloceanspaces.com"
def test_load_data_raises_on_invalid_json(self, s3_loader):
"""Should raise ValueError for invalid JSON input."""
with pytest.raises(ValueError, match="Invalid JSON"):
s3_loader.load_data("not valid json")
def test_load_data_raises_on_missing_required_fields(self, s3_loader):
"""Should raise ValueError when required fields are missing."""
with pytest.raises(ValueError, match="Missing required fields"):
s3_loader.load_data({"aws_access_key_id": "test-key"})
with pytest.raises(ValueError, match="Missing required fields"):
s3_loader.load_data(
{"aws_access_key_id": "test-key", "aws_secret_access_key": "secret"}
)
def test_load_data_skips_unsupported_files(self, s3_loader, mock_boto3):
"""Should skip unsupported file types."""
mock_client = MagicMock()
mock_boto3.client.return_value = mock_client
paginator = MagicMock()
mock_client.get_paginator.return_value = paginator
paginator.paginate.return_value = [
{
"Contents": [
{"Key": "readme.txt"},
{"Key": "image.png"}, # Unsupported
{"Key": "photo.jpg"}, # Unsupported
]
}
]
def get_object_side_effect(Bucket, Key):
mock_body = MagicMock()
mock_body.read.return_value = b"Text content"
return {"Body": mock_body}
mock_client.get_object.side_effect = get_object_side_effect
input_data = {
"aws_access_key_id": "test-key",
"aws_secret_access_key": "test-secret",
"bucket": "test-bucket",
}
docs = s3_loader.load_data(input_data)
# Only txt file should be loaded
assert len(docs) == 1
assert docs[0].extra_info["key"] == "readme.txt"
def test_load_data_uses_corrected_bucket_from_endpoint(self, s3_loader, mock_boto3):
"""Should use bucket name extracted from DO Spaces URL."""
mock_client = MagicMock()
mock_boto3.client.return_value = mock_client
paginator = MagicMock()
mock_client.get_paginator.return_value = paginator
paginator.paginate.return_value = [{"Contents": [{"Key": "file.txt"}]}]
mock_body = MagicMock()
mock_body.read.return_value = b"Content"
mock_client.get_object.return_value = {"Body": mock_body}
input_data = {
"aws_access_key_id": "test-key",
"aws_secret_access_key": "test-secret",
"bucket": "wrong-bucket", # Will be corrected from endpoint
"endpoint_url": "https://mybucket.nyc3.digitaloceanspaces.com",
}
with patch(
"application.parser.remote.s3_loader.validate_url",
side_effect=lambda u: u,
):
docs = s3_loader.load_data(input_data)
# Verify bucket name was corrected
paginator.paginate.assert_called_once_with(Bucket="mybucket", Prefix="")
assert docs[0].extra_info["bucket"] == "mybucket"
class TestProcessDocument:
"""Test document processing."""
def test_process_document_extracts_text(self, s3_loader):
"""Should extract text from document files."""
mock_doc = MagicMock()
mock_doc.text = "Extracted document text"
with patch(
"application.parser.file.bulk.SimpleDirectoryReader"
) as mock_reader_class:
mock_reader = MagicMock()
mock_reader.load_data.return_value = [mock_doc]
mock_reader_class.return_value = mock_reader
with patch("tempfile.NamedTemporaryFile") as mock_temp:
mock_file = MagicMock()
mock_file.__enter__ = MagicMock(return_value=mock_file)
mock_file.__exit__ = MagicMock(return_value=False)
mock_file.name = "/tmp/test.pdf"
mock_temp.return_value = mock_file
with patch("os.path.exists", return_value=True):
with patch("os.unlink"):
result = s3_loader._process_document(
b"PDF content", "document.pdf"
)
assert result == "Extracted document text"
def test_process_document_returns_none_on_error(self, s3_loader):
"""Should return None when document processing fails."""
with patch(
"application.parser.file.bulk.SimpleDirectoryReader"
) as mock_reader_class:
mock_reader_class.side_effect = Exception("Parse error")
with patch("tempfile.NamedTemporaryFile") as mock_temp:
mock_file = MagicMock()
mock_file.__enter__ = MagicMock(return_value=mock_file)
mock_file.__exit__ = MagicMock(return_value=False)
mock_file.name = "/tmp/test.pdf"
mock_temp.return_value = mock_file
with patch("os.path.exists", return_value=True):
with patch("os.unlink"):
result = s3_loader._process_document(
b"PDF content", "document.pdf"
)
assert result is None
def test_process_document_cleans_up_temp_file(self, s3_loader):
"""Should clean up temporary file after processing."""
with patch(
"application.parser.file.bulk.SimpleDirectoryReader"
) as mock_reader_class:
mock_reader = MagicMock()
mock_reader.load_data.return_value = []
mock_reader_class.return_value = mock_reader
with patch("tempfile.NamedTemporaryFile") as mock_temp:
mock_file = MagicMock()
mock_file.__enter__ = MagicMock(return_value=mock_file)
mock_file.__exit__ = MagicMock(return_value=False)
mock_file.name = "/tmp/test.pdf"
mock_temp.return_value = mock_file
with patch("os.path.exists", return_value=True) as mock_exists:
with patch("os.unlink") as mock_unlink:
s3_loader._process_document(b"PDF content", "document.pdf")
mock_exists.assert_called_with("/tmp/test.pdf")
mock_unlink.assert_called_with("/tmp/test.pdf")
class TestListObjectsAdditional:
"""Cover lines 225, 230-232: NoSuchKey error and generic S3 error."""
def test_list_objects_raises_on_no_such_key(self, s3_loader):
"""Cover lines 225, 230-232: NoSuchKey error on ListObjectsV2."""
mock_client = MagicMock()
s3_loader.s3_client = mock_client
mock_client.meta.endpoint_url = "https://nyc3.digitaloceanspaces.com"
paginator = MagicMock()
mock_client.get_paginator.return_value = paginator
paginator.paginate.return_value.__iter__ = MagicMock(
side_effect=ClientError(
{"Error": {"Code": "NoSuchKey", "Message": "No such key"}},
"ListObjectsV2",
)
)
with pytest.raises(Exception, match="S3 error"):
s3_loader.list_objects("test-bucket", "")
def test_list_objects_raises_on_generic_error(self, s3_loader):
"""Cover line 274: generic ClientError raises."""
mock_client = MagicMock()
s3_loader.s3_client = mock_client
mock_client.meta.endpoint_url = "https://s3.amazonaws.com"
paginator = MagicMock()
mock_client.get_paginator.return_value = paginator
paginator.paginate.return_value.__iter__ = MagicMock(
side_effect=ClientError(
{"Error": {"Code": "InternalError", "Message": "Server error"}},
"ListObjectsV2",
)
)
with pytest.raises(Exception, match="S3 error"):
s3_loader.list_objects("test-bucket", "")
class TestGetObjectContentAdditional:
"""Cover lines 293, 299-302: document file and generic error paths."""
def test_get_object_content_supported_document(self, s3_loader):
"""Cover lines 293, 308-309: supported document processed."""
mock_client = MagicMock()
s3_loader.s3_client = mock_client
mock_body = MagicMock()
mock_body.read.return_value = b"PDF bytes"
mock_client.get_object.return_value = {"Body": mock_body}
with patch.object(s3_loader, "_process_document", return_value="Extracted") as mock_proc:
result = s3_loader.get_object_content("bucket", "doc.pdf")
assert result == "Extracted"
mock_proc.assert_called_once_with(b"PDF bytes", "doc.pdf")
def test_get_object_content_generic_client_error(self, s3_loader):
"""Cover lines 299-302: generic ClientError returns None."""
mock_client = MagicMock()
s3_loader.s3_client = mock_client
mock_client.get_object.side_effect = ClientError(
{"Error": {"Code": "InternalError", "Message": "Internal error"}},
"GetObject",
)
result = s3_loader.get_object_content("bucket", "file.txt")
assert result is None
def test_get_object_text_empty_returns_none(self, s3_loader):
"""Cover line 293/303-304: empty text content returns None."""
mock_client = MagicMock()
s3_loader.s3_client = mock_client
mock_body = MagicMock()
mock_body.read.return_value = b""
mock_client.get_object.return_value = {"Body": mock_body}
result = s3_loader.get_object_content("bucket", "empty.txt")
assert result is None
class TestNormalizeEndpointAdditional:
"""Cover lines 13-14, 24: import handling and digitaloceanspaces.com without region."""
def test_do_spaces_no_region(self, s3_loader):
"""Cover line 71-76: digitaloceanspaces.com without region."""
endpoint, bucket = s3_loader._normalize_endpoint_url(
"https://digitaloceanspaces.com", "my-bucket"
)
assert endpoint == "https://digitaloceanspaces.com"
assert bucket == "my-bucket"
class TestProcessDocumentAdditional:
"""Cover lines 346-348: empty documents list returns None."""
def test_process_document_empty_documents_returns_none(self, s3_loader):
"""Cover line 347-348: no documents extracted returns None."""
with patch(
"application.parser.file.bulk.SimpleDirectoryReader"
) as mock_reader_class:
mock_reader = MagicMock()
mock_reader.load_data.return_value = []
mock_reader_class.return_value = mock_reader
with patch("tempfile.NamedTemporaryFile") as mock_temp:
mock_file = MagicMock()
mock_file.__enter__ = MagicMock(return_value=mock_file)
mock_file.__exit__ = MagicMock(return_value=False)
mock_file.name = "/tmp/test.docx"
mock_temp.return_value = mock_file
with patch("os.path.exists", return_value=True):
with patch("os.unlink"):
result = s3_loader._process_document(
b"docx content", "document.docx"
)
assert result is None
class TestSSRFValidation:
"""Ensure user-supplied endpoint_url values cannot target internal networks."""
def test_init_client_rejects_loopback_endpoint(self, s3_loader, mock_boto3):
"""Should refuse to initialize boto3 when endpoint points at localhost."""
with pytest.raises(ValueError, match="Invalid S3 endpoint_url"):
s3_loader._init_client(
aws_access_key_id="k",
aws_secret_access_key="s",
region_name="us-east-1",
endpoint_url="http://127.0.0.1:9000",
bucket="b",
)
mock_boto3.client.assert_not_called()
def test_init_client_rejects_metadata_ip(self, s3_loader, mock_boto3):
"""Should refuse to initialize boto3 when endpoint targets cloud metadata."""
with pytest.raises(ValueError, match="Invalid S3 endpoint_url"):
s3_loader._init_client(
aws_access_key_id="k",
aws_secret_access_key="s",
region_name="us-east-1",
endpoint_url="http://169.254.169.254/",
bucket="b",
)
mock_boto3.client.assert_not_called()
def test_init_client_rejects_private_ip(self, s3_loader, mock_boto3):
"""Should refuse to initialize boto3 when endpoint targets an RFC1918 host."""
with pytest.raises(ValueError, match="Invalid S3 endpoint_url"):
s3_loader._init_client(
aws_access_key_id="k",
aws_secret_access_key="s",
region_name="us-east-1",
endpoint_url="http://10.0.0.5:9000",
bucket="b",
)
mock_boto3.client.assert_not_called()
def test_load_data_rejects_ssrf_endpoint(self, s3_loader, mock_boto3):
"""load_data should surface a ValueError without hitting boto3 for blocked endpoints."""
input_data = {
"aws_access_key_id": "k",
"aws_secret_access_key": "s",
"bucket": "b",
"endpoint_url": "http://localhost:9000",
}
with pytest.raises(ValueError, match="Invalid S3 endpoint_url"):
s3_loader.load_data(input_data)
mock_boto3.client.assert_not_called()
@@ -0,0 +1,200 @@
"""Tests for SharePoint loader."""
from unittest.mock import patch, MagicMock
from application.parser.connectors.share_point.loader import SharePointLoader
def make_response(json_data=None, status_code=200, raise_error=None):
resp = MagicMock()
resp.status_code = status_code
resp.json.return_value = json_data
resp.content = b"test content"
if raise_error is not None:
resp.raise_for_status.side_effect = raise_error
else:
resp.raise_for_status.return_value = None
return resp
class TestSharePointLoaderProcessFile:
"""Test _process_file method."""
def test_size_retrieved_from_root_level(self):
"""Should retrieve size from root of file_metadata, not nested file object."""
loader = SharePointLoader.__new__(SharePointLoader)
file_metadata = {
"id": "test-id",
"name": "test.txt",
"createdDateTime": "2024-01-01T00:00:00Z",
"lastModifiedDateTime": "2024-01-01T00:00:00Z",
"size": 1024,
"file": {
"mimeType": "text/plain"
}
}
doc = loader._process_file(file_metadata, load_content=False)
assert doc is not None
assert doc.extra_info["size"] == 1024
assert doc.extra_info["file_name"] == "test.txt"
assert doc.extra_info["mime_type"] == "text/plain"
def test_size_null_when_missing(self):
"""Should return None when size field is missing."""
loader = SharePointLoader.__new__(SharePointLoader)
file_metadata = {
"id": "test-id",
"name": "test.txt",
"createdDateTime": "2024-01-01T00:00:00Z",
"lastModifiedDateTime": "2024-01-01T00:00:00Z",
"file": {
"mimeType": "text/plain"
}
}
doc = loader._process_file(file_metadata, load_content=False)
assert doc is not None
assert doc.extra_info["size"] is None
class TestSharePointLoaderLoadFileById:
"""Test _load_file_by_id method."""
@patch("application.parser.connectors.share_point.loader.requests.get")
@patch("application.parser.connectors.share_point.loader.SharePointAuth.get_token_info_from_session")
@patch("application.parser.connectors.share_point.loader.SharePointAuth.__init__", return_value=None)
@patch("application.parser.connectors.share_point.loader.SharePointLoader._ensure_valid_token")
def test_load_file_by_id_includes_size_in_select(self, mock_ensure_token, mock_auth_init, mock_get_token, mock_get):
"""Should include size field in $select parameter."""
mock_get_token.return_value = {
"access_token": "test-token",
"refresh_token": "test-refresh"
}
mock_get.return_value = make_response({
"id": "test-id",
"name": "test.txt",
"createdDateTime": "2024-01-01T00:00:00Z",
"lastModifiedDateTime": "2024-01-01T00:00:00Z",
"size": 2048,
"file": {
"mimeType": "text/plain"
}
})
loader = SharePointLoader("test-session")
doc = loader._load_file_by_id("test-id", load_content=False)
assert doc is not None
assert doc.extra_info["size"] == 2048
call_args = mock_get.call_args
params = call_args[1]["params"]
assert "size" in params["$select"]
@patch("application.parser.connectors.share_point.loader.requests.get")
@patch("application.parser.connectors.share_point.loader.SharePointAuth.get_token_info_from_session")
@patch("application.parser.connectors.share_point.loader.SharePointAuth.__init__", return_value=None)
@patch("application.parser.connectors.share_point.loader.SharePointLoader._ensure_valid_token")
def test_load_file_by_id_returns_document_with_size(self, mock_ensure_token, mock_auth_init, mock_get_token, mock_get):
"""Should return document with size from API response."""
mock_get_token.return_value = {
"access_token": "test-token",
"refresh_token": "test-refresh"
}
mock_get.return_value = make_response({
"id": "test-id",
"name": "document.pdf",
"createdDateTime": "2024-01-01T00:00:00Z",
"lastModifiedDateTime": "2024-06-15T10:30:00Z",
"size": 56789,
"file": {
"mimeType": "application/pdf"
}
})
loader = SharePointLoader("test-session")
doc = loader._load_file_by_id("test-id", load_content=False)
assert doc is not None
assert doc.doc_id == "test-id"
assert doc.extra_info["file_name"] == "document.pdf"
assert doc.extra_info["mime_type"] == "application/pdf"
assert doc.extra_info["size"] == 56789
assert doc.extra_info["created_time"] == "2024-01-01T00:00:00Z"
assert doc.extra_info["modified_time"] == "2024-06-15T10:30:00Z"
assert doc.extra_info["source"] == "share_point"
class TestSharePointLoaderListItems:
"""Test _list_items_in_parent method."""
@patch("application.parser.connectors.share_point.loader.requests.get")
@patch("application.parser.connectors.share_point.loader.SharePointAuth.get_token_info_from_session")
@patch("application.parser.connectors.share_point.loader.SharePointAuth.__init__", return_value=None)
@patch("application.parser.connectors.share_point.loader.SharePointLoader._ensure_valid_token")
def test_list_items_includes_size_in_select(self, mock_ensure_token, mock_auth_init, mock_get_token, mock_get):
"""Should include size field in $select parameter when listing items."""
mock_get_token.return_value = {
"access_token": "test-token",
"refresh_token": "test-refresh"
}
mock_get.return_value = make_response({
"value": [
{
"id": "file-1",
"name": "file1.txt",
"createdDateTime": "2024-01-01T00:00:00Z",
"lastModifiedDateTime": "2024-01-01T00:00:00Z",
"size": 12345,
"file": {
"mimeType": "text/plain"
}
}
]
})
loader = SharePointLoader("test-session")
docs = loader._list_items_in_parent("parent-id", limit=10, load_content=False)
assert len(docs) == 1
assert docs[0].extra_info["size"] == 12345
call_args = mock_get.call_args
params = call_args[1]["params"]
assert "size" in params["$select"]
@patch("application.parser.connectors.share_point.loader.requests.get")
@patch("application.parser.connectors.share_point.loader.SharePointAuth.get_token_info_from_session")
@patch("application.parser.connectors.share_point.loader.SharePointAuth.__init__", return_value=None)
@patch("application.parser.connectors.share_point.loader.SharePointLoader._ensure_valid_token")
def test_list_items_folders_include_size(self, mock_ensure_token, mock_auth_init, mock_get_token, mock_get):
"""Should include size for folders as well."""
mock_get_token.return_value = {
"access_token": "test-token",
"refresh_token": "test-refresh"
}
mock_get.return_value = make_response({
"value": [
{
"id": "folder-1",
"name": "MyFolder",
"createdDateTime": "2024-01-01T00:00:00Z",
"lastModifiedDateTime": "2024-01-01T00:00:00Z",
"size": 0,
"folder": {}
}
]
})
loader = SharePointLoader("test-session")
docs = loader._list_items_in_parent("parent-id", limit=10, load_content=False)
assert len(docs) == 1
assert docs[0].extra_info["is_folder"] is True
assert docs[0].extra_info["size"] == 0
+332
View File
@@ -0,0 +1,332 @@
"""Comprehensive tests for application/parser/remote/sitemap_loader.py
Covers: SitemapLoader (init, load_data, _extract_urls, _is_sitemap,
_parse_sitemap, URL validation, error handling).
"""
from unittest.mock import MagicMock, patch
import pytest
import requests
from application.parser.remote.sitemap_loader import SitemapLoader
from application.parser.schema.base import Document
# =====================================================================
# SitemapLoader - Init
# =====================================================================
@pytest.mark.unit
class TestSitemapLoaderInit:
def test_default_limit(self):
loader = SitemapLoader()
assert loader.limit == 20
def test_custom_limit(self):
loader = SitemapLoader(limit=5)
assert loader.limit == 5
def test_has_loader_class(self):
loader = SitemapLoader()
assert not hasattr(loader, "loader")
# =====================================================================
# _is_sitemap
# =====================================================================
@pytest.mark.unit
class TestIsSitemap:
def test_xml_content_type(self):
loader = SitemapLoader()
response = MagicMock()
response.headers = {"Content-Type": "application/xml"}
response.url = "https://example.com/sitemap.xml"
response.text = ""
assert loader._is_sitemap(response) is True
def test_xml_url_extension(self):
loader = SitemapLoader()
response = MagicMock()
response.headers = {"Content-Type": "text/html"}
response.url = "https://example.com/sitemap.xml"
response.text = ""
assert loader._is_sitemap(response) is True
def test_sitemapindex_in_body(self):
loader = SitemapLoader()
response = MagicMock()
response.headers = {"Content-Type": "text/html"}
response.url = "https://example.com/sitemap"
response.text = "<sitemapindex><sitemap></sitemap></sitemapindex>"
assert loader._is_sitemap(response) is True
def test_urlset_in_body(self):
loader = SitemapLoader()
response = MagicMock()
response.headers = {"Content-Type": "text/html"}
response.url = "https://example.com/page"
response.text = "<urlset><url></url></urlset>"
assert loader._is_sitemap(response) is True
def test_regular_page(self):
loader = SitemapLoader()
response = MagicMock()
response.headers = {"Content-Type": "text/html"}
response.url = "https://example.com/about"
response.text = "<html><body>About us</body></html>"
assert loader._is_sitemap(response) is False
def test_text_xml_content_type(self):
loader = SitemapLoader()
response = MagicMock()
response.headers = {"Content-Type": "text/xml; charset=utf-8"}
response.url = "https://example.com/feed"
response.text = ""
assert loader._is_sitemap(response) is True
# =====================================================================
# _parse_sitemap
# =====================================================================
@pytest.mark.unit
class TestParseSitemap:
def test_parse_basic_sitemap(self):
loader = SitemapLoader()
sitemap_xml = b"""<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://example.com/page1</loc></url>
<url><loc>https://example.com/page2</loc></url>
</urlset>"""
urls = loader._parse_sitemap(sitemap_xml)
assert "https://example.com/page1" in urls
assert "https://example.com/page2" in urls
def test_parse_nested_sitemap(self):
loader = SitemapLoader()
parent_xml = b"""<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap>
<loc>https://example.com/sitemap-child.xml</loc>
</sitemap>
</sitemapindex>"""
with patch.object(
loader, "_extract_urls",
return_value=["https://example.com/page1"]
):
urls = loader._parse_sitemap(parent_xml)
assert "https://example.com/page1" in urls
def test_parse_empty_sitemap(self):
loader = SitemapLoader()
sitemap_xml = b"""<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
</urlset>"""
urls = loader._parse_sitemap(sitemap_xml)
assert urls == []
def test_parse_skips_empty_loc_entries(self):
"""Empty or self-closing <loc> tags must not yield None URLs."""
loader = SitemapLoader()
sitemap_xml = b"""<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc></loc></url>
<url><loc>https://example.com/p</loc></url>
<url><loc/></url>
</urlset>"""
urls = loader._parse_sitemap(sitemap_xml)
assert urls == ["https://example.com/p"]
# =====================================================================
# _extract_urls
# =====================================================================
@pytest.mark.unit
class TestExtractUrls:
@patch("application.parser.remote.sitemap_loader.pinned_request")
def test_extract_urls_from_sitemap(self, mock_pinned_request):
loader = SitemapLoader()
response = MagicMock()
response.headers = {"Content-Type": "application/xml"}
response.url = "https://example.com/sitemap.xml"
response.text = "<urlset><url><loc>https://example.com/p</loc></url></urlset>"
response.content = b"""<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://example.com/p</loc></url>
</urlset>"""
response.raise_for_status.return_value = None
mock_pinned_request.return_value = response
urls = loader._extract_urls("https://example.com/sitemap.xml")
assert "https://example.com/p" in urls
@patch("application.parser.remote.sitemap_loader.pinned_request")
def test_extract_urls_not_sitemap(self, mock_pinned_request):
loader = SitemapLoader()
response = MagicMock()
response.headers = {"Content-Type": "text/html"}
response.url = "https://example.com/page"
response.text = "<html>Normal page</html>"
response.raise_for_status.return_value = None
mock_pinned_request.return_value = response
urls = loader._extract_urls("https://example.com/page")
assert urls == ["https://example.com/page"]
@patch("application.parser.remote.sitemap_loader.pinned_request")
def test_extract_urls_http_error(self, mock_pinned_request):
loader = SitemapLoader()
mock_pinned_request.side_effect = requests.exceptions.HTTPError("404")
urls = loader._extract_urls("https://example.com/missing")
assert urls == []
@patch("application.parser.remote.sitemap_loader.pinned_request")
def test_extract_urls_connection_error(self, mock_pinned_request):
loader = SitemapLoader()
mock_pinned_request.side_effect = requests.exceptions.ConnectionError()
urls = loader._extract_urls("https://example.com/bad")
assert urls == []
def test_extract_urls_ssrf_blocked(self):
from application.security.safe_url import UnsafeUserUrlError
loader = SitemapLoader()
with patch(
"application.parser.remote.sitemap_loader.pinned_request",
side_effect=UnsafeUserUrlError("blocked"),
):
urls = loader._extract_urls("http://169.254.169.254/")
assert urls == []
# =====================================================================
# load_data
# =====================================================================
@pytest.mark.unit
class TestSitemapLoaderLoadData:
@patch("application.parser.remote.sitemap_loader.validate_url")
@patch("application.parser.remote.sitemap_loader.pinned_request")
def test_load_data_success(self, mock_pinned_request, mock_validate):
loader = SitemapLoader(limit=10)
mock_validate.side_effect = lambda url: url
response = MagicMock()
response.text = "Page body"
response.raise_for_status.return_value = None
mock_pinned_request.return_value = response
with patch.object(
loader, "_extract_urls",
return_value=["https://example.com/page1"]
):
docs = loader.load_data("https://example.com/sitemap.xml")
assert len(docs) == 1
assert isinstance(docs[0], Document)
assert docs[0].text == "Page body"
assert docs[0].extra_info == {"source": "https://example.com/page1"}
@patch("application.parser.remote.sitemap_loader.validate_url")
def test_load_data_no_urls(self, mock_validate):
loader = SitemapLoader()
with patch.object(loader, "_extract_urls", return_value=[]):
docs = loader.load_data("https://example.com/empty-sitemap.xml")
assert docs == []
@patch("application.parser.remote.sitemap_loader.validate_url")
@patch("application.parser.remote.sitemap_loader.pinned_request")
def test_load_data_list_input(self, mock_pinned_request, mock_validate):
loader = SitemapLoader()
mock_validate.side_effect = lambda url: url
response = MagicMock()
response.text = "List body"
response.raise_for_status.return_value = None
mock_pinned_request.return_value = response
with patch.object(
loader, "_extract_urls",
return_value=["https://example.com/page1"]
):
docs = loader.load_data(["https://example.com/sitemap.xml"])
assert len(docs) == 1
assert docs[0].text == "List body"
@patch("application.parser.remote.sitemap_loader.validate_url")
@patch("application.parser.remote.sitemap_loader.pinned_request")
def test_load_data_respects_limit(self, mock_pinned_request, mock_validate):
loader = SitemapLoader(limit=2)
mock_validate.side_effect = lambda url: url
response = MagicMock()
response.text = "Limited body"
response.raise_for_status.return_value = None
mock_pinned_request.return_value = response
urls = [f"https://example.com/page{i}" for i in range(10)]
with patch.object(loader, "_extract_urls", return_value=urls):
docs = loader.load_data("https://example.com/sitemap.xml")
assert len(docs) == 2
assert mock_pinned_request.call_count == 2
@patch("application.parser.remote.sitemap_loader.validate_url")
@patch("application.parser.remote.sitemap_loader.pinned_request")
def test_load_data_handles_url_error(self, mock_pinned_request, mock_validate):
loader = SitemapLoader()
mock_validate.side_effect = lambda url: url
mock_pinned_request.side_effect = Exception("Load failed")
with patch.object(
loader, "_extract_urls",
return_value=["https://example.com/broken"]
):
docs = loader.load_data("https://example.com/sitemap.xml")
assert docs == []
def test_load_data_ssrf_blocked(self):
from application.core.url_validation import SSRFError
loader = SitemapLoader()
with patch(
"application.parser.remote.sitemap_loader.validate_url",
side_effect=SSRFError("blocked"),
):
docs = loader.load_data("http://169.254.169.254/")
assert docs == []
@patch("application.parser.remote.sitemap_loader.validate_url")
@patch("application.parser.remote.sitemap_loader.pinned_request")
def test_load_data_no_limit(self, mock_pinned_request, mock_validate):
loader = SitemapLoader(limit=None)
mock_validate.side_effect = lambda url: url
response = MagicMock()
response.text = "No limit body"
response.raise_for_status.return_value = None
mock_pinned_request.return_value = response
urls = [f"https://example.com/page{i}" for i in range(5)]
with patch.object(loader, "_extract_urls", return_value=urls):
docs = loader.load_data("https://example.com/sitemap.xml")
assert len(docs) == 5
+316
View File
@@ -0,0 +1,316 @@
import pytest
from unittest.mock import patch, MagicMock
from urllib.parse import urlparse
from application.core.url_validation import SSRFError
from application.parser.remote.web_loader import WebLoader, headers
from application.parser.schema.base import Document
from langchain_core.documents import Document as LCDocument
def _mock_validate_url(url):
"""Mock validate_url that allows test URLs through and prepends scheme like the real impl."""
if not urlparse(url).scheme:
url = "http://" + url
return url
def _fake_response(html: str) -> MagicMock:
"""Build a MagicMock that quacks like a requests.Response for our purposes."""
response = MagicMock()
response.text = html
response.raise_for_status.return_value = None
return response
@pytest.fixture
def web_loader():
return WebLoader()
class TestWebLoaderInitialization:
"""Test WebLoader initialization."""
def test_init_has_no_loader_attribute(self, web_loader):
"""Post-pinned-request migration, WebLoader no longer keeps a langchain loader class."""
assert not hasattr(web_loader, "loader")
class TestWebLoaderHeaders:
"""Test WebLoader headers configuration."""
def test_headers_defined(self):
assert isinstance(headers, dict)
assert "User-Agent" in headers
assert "Accept" in headers
assert "Accept-Language" in headers
assert "Referer" in headers
assert "DNT" in headers
assert "Connection" in headers
assert "Upgrade-Insecure-Requests" in headers
def test_headers_values(self):
assert headers["User-Agent"] == "Mozilla/5.0"
assert "text/html" in headers["Accept"]
assert headers["Referer"] == "https://www.google.com/"
assert headers["DNT"] == "1"
assert headers["Connection"] == "keep-alive"
class TestWebLoaderLoadData:
"""Test WebLoader load_data method."""
@patch("application.parser.remote.web_loader.validate_url", side_effect=_mock_validate_url)
@patch("application.parser.remote.web_loader.pinned_request")
def test_load_data_single_url_string(self, mock_pinned_request, mock_validate, web_loader):
mock_pinned_request.return_value = _fake_response(
"<html lang='en'><head><title>Test Page</title></head>"
"<body><p>Test web page content</p></body></html>"
)
result = web_loader.load_data("https://example.com")
assert len(result) == 1
assert isinstance(result[0], Document)
assert result[0].text == "Test Page\nTest web page content"
assert result[0].extra_info == {
"source": "https://example.com",
"title": "Test Page",
"language": "en",
}
mock_pinned_request.assert_called_once_with(
"GET", "https://example.com", headers=headers, timeout=30
)
@patch("application.parser.remote.web_loader.validate_url", side_effect=_mock_validate_url)
@patch("application.parser.remote.web_loader.pinned_request")
def test_load_data_multiple_urls_list(self, mock_pinned_request, mock_validate, web_loader):
mock_pinned_request.side_effect = [
_fake_response("<html><body>Content from site 1</body></html>"),
_fake_response("<html><body>Content from site 2</body></html>"),
]
urls = ["https://site1.com", "https://site2.com"]
result = web_loader.load_data(urls)
assert len(result) == 2
assert all(isinstance(doc, Document) for doc in result)
assert result[0].text == "Content from site 1"
assert result[1].text == "Content from site 2"
assert result[0].extra_info == {"source": "https://site1.com"}
assert result[1].extra_info == {"source": "https://site2.com"}
assert mock_pinned_request.call_count == 2
mock_pinned_request.assert_any_call(
"GET", "https://site1.com", headers=headers, timeout=30
)
mock_pinned_request.assert_any_call(
"GET", "https://site2.com", headers=headers, timeout=30
)
@patch("application.parser.remote.web_loader.validate_url", side_effect=_mock_validate_url)
@patch("application.parser.remote.web_loader.pinned_request")
def test_load_data_url_without_scheme(self, mock_pinned_request, mock_validate, web_loader):
mock_pinned_request.return_value = _fake_response(
"<html><body>Schemeless</body></html>"
)
result = web_loader.load_data("example.com")
assert len(result) == 1
mock_pinned_request.assert_called_once_with(
"GET", "http://example.com", headers=headers, timeout=30
)
@patch("application.parser.remote.web_loader.validate_url", side_effect=_mock_validate_url)
@patch("application.parser.remote.web_loader.pinned_request")
def test_load_data_url_with_scheme(self, mock_pinned_request, mock_validate, web_loader):
mock_pinned_request.return_value = _fake_response(
"<html><body>Schemed</body></html>"
)
result = web_loader.load_data("https://example.com")
assert len(result) == 1
mock_pinned_request.assert_called_once_with(
"GET", "https://example.com", headers=headers, timeout=30
)
@patch("application.parser.remote.web_loader.validate_url", side_effect=_mock_validate_url)
@patch("application.parser.remote.web_loader.pinned_request")
def test_load_data_skips_pages_without_title(self, mock_pinned_request, mock_validate, web_loader):
"""A page without <title> or <html lang=...> still loads, just with bare metadata."""
mock_pinned_request.return_value = _fake_response("<p>Bare body</p>")
result = web_loader.load_data("https://example.com")
assert len(result) == 1
assert result[0].text == "Bare body"
assert result[0].extra_info == {"source": "https://example.com"}
@patch("application.parser.remote.web_loader.validate_url", side_effect=_mock_validate_url)
@patch("application.parser.remote.web_loader.pinned_request")
def test_load_data_extracts_title_with_nested_markup(self, mock_pinned_request, mock_validate, web_loader):
"""<title> with nested-looking content must still produce a non-empty title.
BS4's `html.parser` differs across Python/bs4 versions: some treat
`<title>` as RAWTEXT (per HTML5 spec) and return the literal string
``"Hello <span>World</span>"``; others parse the inner tag and return
``"HelloWorld"``. The regression guarded here is that ``soup.title.string``
returns ``None`` in *either* case, dropping the title entirely — using
``get_text`` keeps it. Assert only that the title survives.
"""
mock_pinned_request.return_value = _fake_response(
"<html><head><title>Hello <span>World</span></title></head>"
"<body><p>x</p></body></html>"
)
result = web_loader.load_data("https://example.com")
title = result[0].extra_info.get("title")
assert title, "title was dropped — soup.title.string regression?"
assert "Hello" in title
class TestWebLoaderErrorHandling:
"""Test WebLoader error handling."""
@patch("application.parser.remote.web_loader.validate_url", side_effect=_mock_validate_url)
@patch("application.parser.remote.web_loader.pinned_request")
@patch("application.parser.remote.web_loader.logging")
def test_load_data_single_url_error(self, mock_logging, mock_pinned_request, mock_validate, web_loader):
mock_pinned_request.side_effect = Exception("Network error")
result = web_loader.load_data("https://invalid-url.com")
assert result == []
mock_logging.error.assert_called_once()
error_call = mock_logging.error.call_args
assert "Error processing URL https://invalid-url.com" in error_call[0][0]
assert error_call[1]["exc_info"] is True
@patch("application.parser.remote.web_loader.validate_url", side_effect=_mock_validate_url)
@patch("application.parser.remote.web_loader.pinned_request")
@patch("application.parser.remote.web_loader.logging")
def test_load_data_partial_failure(self, mock_logging, mock_pinned_request, mock_validate, web_loader):
mock_pinned_request.side_effect = [
_fake_response("<html><body>Success content</body></html>"),
Exception("Network error"),
]
urls = ["https://good-url.com", "https://bad-url.com"]
result = web_loader.load_data(urls)
assert len(result) == 1
assert result[0].text == "Success content"
assert result[0].extra_info == {"source": "https://good-url.com"}
mock_logging.error.assert_called_once()
error_call = mock_logging.error.call_args
assert "Error processing URL https://bad-url.com" in error_call[0][0]
class TestWebLoaderSSRF:
"""Test WebLoader SSRF protection."""
@patch("application.parser.remote.web_loader.validate_url")
@patch("application.parser.remote.web_loader.pinned_request")
def test_load_data_skips_url_failing_ssrf_validation(self, mock_pinned_request, mock_validate, web_loader):
"""A URL that fails SSRF validation must be skipped, never reaching the fetcher."""
mock_validate.side_effect = SSRFError("Access to private/internal IP addresses is not allowed.")
result = web_loader.load_data("http://169.254.169.254/latest/meta-data/")
assert result == []
mock_validate.assert_called_once_with("http://169.254.169.254/latest/meta-data/")
mock_pinned_request.assert_not_called()
@patch("application.parser.remote.web_loader.validate_url")
@patch("application.parser.remote.web_loader.logging")
def test_load_data_logs_warning_on_ssrf_failure(self, mock_logging, mock_validate, web_loader):
mock_validate.side_effect = SSRFError("blocked")
web_loader.load_data("http://127.0.0.1")
mock_logging.warning.assert_called_once()
warning_msg = mock_logging.warning.call_args[0][0]
assert "SSRF" in warning_msg
assert "127.0.0.1" in warning_msg
@patch("application.parser.remote.web_loader.validate_url")
@patch("application.parser.remote.web_loader.pinned_request")
def test_load_data_mixed_safe_and_unsafe_urls(self, mock_pinned_request, mock_validate, web_loader):
def validate(url):
if "169.254.169.254" in url:
raise SSRFError("metadata blocked")
return url
mock_validate.side_effect = validate
mock_pinned_request.return_value = _fake_response(
"<html><body>Public page</body></html>"
)
urls = ["https://example.com", "http://169.254.169.254/latest/meta-data/"]
result = web_loader.load_data(urls)
assert len(result) == 1
assert result[0].text == "Public page"
mock_pinned_request.assert_called_once_with(
"GET", "https://example.com", headers=headers, timeout=30
)
class TestWebLoaderEdgeCases:
"""Test WebLoader edge cases."""
def test_load_data_empty_list(self, web_loader):
result = web_loader.load_data([])
assert result == []
@patch("application.parser.remote.web_loader.validate_url", side_effect=_mock_validate_url)
@patch("application.parser.remote.web_loader.pinned_request")
def test_load_data_empty_response_body(self, mock_pinned_request, mock_validate, web_loader):
mock_pinned_request.return_value = _fake_response("")
result = web_loader.load_data("https://empty-page.com")
assert len(result) == 1
assert result[0].text == ""
assert result[0].extra_info == {"source": "https://empty-page.com"}
def test_url_scheme_detection(self):
"""Test URL scheme detection logic."""
assert urlparse("https://example.com").scheme == "https"
assert urlparse("http://example.com").scheme == "http"
assert urlparse("ftp://example.com").scheme == "ftp"
assert urlparse("example.com").scheme == ""
assert urlparse("www.example.com").scheme == ""
class TestWebLoaderIntegration:
"""Test WebLoader integration with base class."""
def test_inherits_from_base_remote(self, web_loader):
from application.parser.remote.base import BaseRemote
assert isinstance(web_loader, BaseRemote)
def test_implements_load_data_method(self, web_loader):
assert hasattr(web_loader, "load_data")
assert callable(web_loader.load_data)
@patch("application.parser.remote.web_loader.validate_url", side_effect=_mock_validate_url)
@patch("application.parser.remote.web_loader.pinned_request")
def test_load_langchain_documents_method(self, mock_pinned_request, mock_validate, web_loader):
mock_pinned_request.return_value = _fake_response(
"<html><head><title>Test Page</title></head>"
"<body><p>Test web page content</p></body></html>"
)
result = web_loader.load_langchain_documents(inputs="https://example.com")
assert len(result) == 1
assert isinstance(result[0], LCDocument)
assert "Test web page content" in result[0].page_content
assert result[0].metadata["source"] == "https://example.com"
assert result[0].metadata["title"] == "Test Page"