Archon/python/tests/test_url_canonicalization.py
Wirasm 3e204b0be1
Fix race condition in concurrent crawling with unique source IDs (#472)
* Fix race condition in concurrent crawling with unique source IDs

- Add unique hash-based source_id generation to prevent conflicts
- Separate source identification from display with three fields:
  - source_id: 16-char SHA256 hash for unique identification
  - source_url: Original URL for tracking
  - source_display_name: Human-friendly name for UI
- Add comprehensive test suite validating the fix
- Migrate existing data with backward compatibility

* Fix title generation to use source_display_name for better AI context

- Pass source_display_name to title generation function
- Use display name in AI prompt instead of hash-based source_id
- Results in more specific, meaningful titles for each source

* Skip AI title generation when display name is available

- Use source_display_name directly as title to avoid unnecessary AI calls
- More efficient and predictable than AI-generated titles
- Keep AI generation only as fallback for backward compatibility

* Fix critical issues from code review

- Add missing os import to prevent NameError crash
- Remove unused imports (pytest, Mock, patch, hashlib, urlparse, etc.)
- Fix GitHub API capitalization consistency
- Reuse existing DocumentStorageService instance
- Update test expectations to match corrected capitalization

Addresses CodeRabbit review feedback on PR #472

* Add safety improvements from code review

- Truncate display names to 100 chars when used as titles
- Document hash collision probability (negligible for <1M sources)

Simple, pragmatic fixes per KISS principle

* Fix code extraction to use hash-based source_ids and improve display names

- Fixed critical bug where code extraction was using old domain-based source_ids
- Updated code extraction service to accept source_id as parameter instead of extracting from URL
- Added special handling for llms.txt and sitemap.xml files in display names
- Added comprehensive tests for source_id handling in code extraction
- Removed unused urlparse import from code_extraction_service.py

This fixes the foreign key constraint errors that were preventing code examples
from being stored after the source_id architecture refactor.

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix critical variable shadowing and source_type determination issues

- Fixed variable shadowing in document_storage_operations.py where source_url parameter
  was being overwritten by document URLs, causing incorrect source_url in database
- Fixed source_type determination to use actual URLs instead of hash-based source_id
- Added comprehensive tests for source URL preservation
- Ensure source_type is correctly set to "file" for file uploads, "url" for web crawls

The variable shadowing bug was causing sitemap sources to have the wrong source_url
(last crawled page instead of sitemap URL). The source_type bug would mark all
sources as "url" even for file uploads due to hash-based IDs not starting with "file_".

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix URL canonicalization and document metrics calculation

- Implement proper URL canonicalization to prevent duplicate sources
  - Remove trailing slashes (except root)
  - Remove URL fragments
  - Remove tracking parameters (utm_*, gclid, fbclid, etc.)
  - Sort query parameters for consistency
  - Remove default ports (80 for HTTP, 443 for HTTPS)
  - Normalize scheme and domain to lowercase

- Fix avg_chunks_per_doc calculation to avoid division by zero
  - Track processed_docs count separately from total crawl_results
  - Handle all-empty document sets gracefully
  - Show processed/total in logs for better visibility

- Add comprehensive tests for both fixes
  - 10 test cases for URL canonicalization edge cases
  - 4 test cases for document metrics calculation

This prevents database constraint violations when crawling the same
content with URL variations and provides accurate metrics in logs.

* Fix synchronous extract_source_summary blocking async event loop

- Run extract_source_summary in thread pool using asyncio.to_thread
- Prevents blocking the async event loop during AI summary generation
- Preserves exact error handling and fallback behavior
- Variables (source_id, combined_content) properly passed to thread

Added comprehensive tests verifying:
- Function runs in thread without blocking
- Error handling works correctly with fallback
- Multiple sources can be processed
- Thread safety with variable passing

* Fix synchronous update_source_info blocking async event loop

- Run update_source_info in thread pool using asyncio.to_thread
- Prevents blocking the async event loop during database operations
- Preserves exact error handling and fallback behavior
- All kwargs properly passed to thread execution

Added comprehensive tests verifying:
- Function runs in thread without blocking
- Error handling triggers fallback correctly
- All kwargs are preserved when passed to thread
- Existing extract_source_summary tests still pass

* Fix race condition in source creation using upsert

- Replace INSERT with UPSERT for new sources to prevent PRIMARY KEY violations
- Handles concurrent crawls attempting to create the same source
- Maintains existing UPDATE behavior for sources that already exist

Added comprehensive tests verifying:
- Concurrent source creation doesn't fail
- Upsert is used for new sources (not insert)
- Update is still used for existing sources
- Async concurrent operations work correctly
- Race conditions with delays are handled

This prevents database constraint errors when multiple crawls target
the same URL simultaneously.

* Add migration detection UI components

Add MigrationBanner component with clear user instructions for database schema updates. Add useMigrationStatus hook for periodic health check monitoring with graceful error handling.

* Integrate migration banner into main app

Add migration status monitoring and banner display to App.tsx. Shows migration banner when database schema updates are required.

* Enhance backend startup error instructions

Add detailed Docker restart instructions and migration script guidance. Improves user experience when encountering startup failures.

* Add database schema caching to health endpoint

Implement smart caching for schema validation to prevent repeated database queries. Cache successful validations permanently and throttle failures to 30-second intervals. Replace debug prints with proper logging.

* Clean up knowledge API imports and logging

Remove duplicate import statements and redundant logging. Improves code clarity and reduces log noise.

* Remove unused instructions prop from MigrationBanner

Clean up component API by removing instructions prop that was accepted but never rendered. Simplifies the interface and eliminates dead code while keeping the functional hardcoded migration steps.

* Add schema_valid flag to migration_required health response

Add schema_valid: false flag to health endpoint response when database schema migration is required. Improves API consistency without changing existing behavior.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-29 14:54:16 +03:00

222 lines
8.6 KiB
Python

"""
Test URL canonicalization in source ID generation.
This test ensures that URLs are properly normalized before hashing
to prevent duplicate sources from URL variations.
"""
import pytest
from src.server.services.crawling.helpers.url_handler import URLHandler
class TestURLCanonicalization:
"""Test that URL canonicalization works correctly for source ID generation."""
def test_trailing_slash_normalization(self):
"""Test that trailing slashes are handled consistently."""
handler = URLHandler()
# These should generate the same ID
url1 = "https://example.com/path"
url2 = "https://example.com/path/"
id1 = handler.generate_unique_source_id(url1)
id2 = handler.generate_unique_source_id(url2)
assert id1 == id2, "URLs with/without trailing slash should generate same ID"
# Root path should keep its slash
root1 = "https://example.com"
root2 = "https://example.com/"
root_id1 = handler.generate_unique_source_id(root1)
root_id2 = handler.generate_unique_source_id(root2)
# These should be the same (both normalize to https://example.com/)
assert root_id1 == root_id2, "Root URLs should normalize consistently"
def test_fragment_removal(self):
"""Test that URL fragments are removed."""
handler = URLHandler()
urls = [
"https://example.com/page",
"https://example.com/page#section1",
"https://example.com/page#section2",
"https://example.com/page#",
]
ids = [handler.generate_unique_source_id(url) for url in urls]
# All should generate the same ID
assert len(set(ids)) == 1, "URLs with different fragments should generate same ID"
def test_tracking_param_removal(self):
"""Test that tracking parameters are removed."""
handler = URLHandler()
# URL without tracking params
clean_url = "https://example.com/page?important=value"
# URLs with various tracking params
tracked_urls = [
"https://example.com/page?important=value&utm_source=google",
"https://example.com/page?utm_medium=email&important=value",
"https://example.com/page?important=value&fbclid=abc123",
"https://example.com/page?gclid=xyz&important=value&utm_campaign=test",
"https://example.com/page?important=value&ref=homepage",
"https://example.com/page?source=newsletter&important=value",
]
clean_id = handler.generate_unique_source_id(clean_url)
tracked_ids = [handler.generate_unique_source_id(url) for url in tracked_urls]
# All tracked URLs should generate the same ID as the clean URL
for tracked_id in tracked_ids:
assert tracked_id == clean_id, "URLs with tracking params should match clean URL"
def test_query_param_sorting(self):
"""Test that query parameters are sorted for consistency."""
handler = URLHandler()
urls = [
"https://example.com/page?a=1&b=2&c=3",
"https://example.com/page?c=3&a=1&b=2",
"https://example.com/page?b=2&c=3&a=1",
]
ids = [handler.generate_unique_source_id(url) for url in urls]
# All should generate the same ID
assert len(set(ids)) == 1, "URLs with reordered query params should generate same ID"
def test_default_port_removal(self):
"""Test that default ports are removed."""
handler = URLHandler()
# HTTP default port (80)
http_urls = [
"http://example.com/page",
"http://example.com:80/page",
]
http_ids = [handler.generate_unique_source_id(url) for url in http_urls]
assert len(set(http_ids)) == 1, "HTTP URLs with/without :80 should generate same ID"
# HTTPS default port (443)
https_urls = [
"https://example.com/page",
"https://example.com:443/page",
]
https_ids = [handler.generate_unique_source_id(url) for url in https_urls]
assert len(set(https_ids)) == 1, "HTTPS URLs with/without :443 should generate same ID"
# Non-default ports should be preserved
url1 = "https://example.com:8080/page"
url2 = "https://example.com:9090/page"
id1 = handler.generate_unique_source_id(url1)
id2 = handler.generate_unique_source_id(url2)
assert id1 != id2, "URLs with different non-default ports should generate different IDs"
def test_case_normalization(self):
"""Test that scheme and domain are lowercased."""
handler = URLHandler()
urls = [
"https://example.com/Path/To/Page",
"HTTPS://EXAMPLE.COM/Path/To/Page",
"https://Example.Com/Path/To/Page",
"HTTPs://example.COM/Path/To/Page",
]
ids = [handler.generate_unique_source_id(url) for url in urls]
# All should generate the same ID (path case is preserved)
assert len(set(ids)) == 1, "URLs with different case in scheme/domain should generate same ID"
# But different paths should generate different IDs
path_urls = [
"https://example.com/path",
"https://example.com/Path",
"https://example.com/PATH",
]
path_ids = [handler.generate_unique_source_id(url) for url in path_urls]
# These should be different (path case matters)
assert len(set(path_ids)) == 3, "URLs with different path case should generate different IDs"
def test_complex_canonicalization(self):
"""Test complex URL with multiple normalizations needed."""
handler = URLHandler()
urls = [
"https://example.com/page",
"HTTPS://EXAMPLE.COM:443/page/",
"https://Example.com/page#section",
"https://example.com/page/?utm_source=test",
"https://example.com:443/page?utm_campaign=abc#footer",
]
ids = [handler.generate_unique_source_id(url) for url in urls]
# All should generate the same ID
assert len(set(ids)) == 1, "Complex URLs should normalize to same ID"
def test_edge_cases(self):
"""Test edge cases and error handling."""
handler = URLHandler()
# Empty URL
empty_id = handler.generate_unique_source_id("")
assert len(empty_id) == 16, "Empty URL should still generate valid ID"
# Invalid URL
invalid_id = handler.generate_unique_source_id("not-a-url")
assert len(invalid_id) == 16, "Invalid URL should still generate valid ID"
# URL with special characters
special_url = "https://example.com/page?key=value%20with%20spaces"
special_id = handler.generate_unique_source_id(special_url)
assert len(special_id) == 16, "URL with encoded chars should generate valid ID"
# Very long URL
long_url = "https://example.com/" + "a" * 1000
long_id = handler.generate_unique_source_id(long_url)
assert len(long_id) == 16, "Long URL should generate valid ID"
def test_preserves_important_params(self):
"""Test that non-tracking params are preserved."""
handler = URLHandler()
# These have different important params, should be different
url1 = "https://api.example.com/v1/users?page=1"
url2 = "https://api.example.com/v1/users?page=2"
id1 = handler.generate_unique_source_id(url1)
id2 = handler.generate_unique_source_id(url2)
assert id1 != id2, "URLs with different important params should generate different IDs"
# But tracking params should still be removed
url3 = "https://api.example.com/v1/users?page=1&utm_source=docs"
id3 = handler.generate_unique_source_id(url3)
assert id3 == id1, "Adding tracking params shouldn't change ID"
def test_local_file_paths(self):
"""Test handling of local file paths."""
handler = URLHandler()
# File URLs
file_url = "file:///Users/test/document.pdf"
file_id = handler.generate_unique_source_id(file_url)
assert len(file_id) == 16, "File URL should generate valid ID"
# Relative paths
relative_path = "../documents/file.txt"
relative_id = handler.generate_unique_source_id(relative_path)
assert len(relative_id) == 16, "Relative path should generate valid ID"