596 lines
19 KiB
Python
596 lines
19 KiB
Python
# This is free software for the public good of a permacomputer hosted at
|
|
# permacomputer.com, an always-on computer by the people, for the people.
|
|
# One which is durable, easy to repair, & distributed like tap water
|
|
# for machine learning intelligence.
|
|
#
|
|
# The permacomputer is community-owned infrastructure optimized around
|
|
# four values:
|
|
#
|
|
# TRUTH First principles, math & science, open source code freely distributed
|
|
# FREEDOM Voluntary partnerships, freedom from tyranny & corporate control
|
|
# HARMONY Minimal waste, self-renewing systems with diverse thriving connections
|
|
# LOVE Be yourself without hurting others, cooperation through natural law
|
|
#
|
|
# This software contributes to that vision by archiving the web, preserving digital knowledge before it disappears.
|
|
# Code is seeds to sprout on any abandoned technology.
|
|
|
|
"""
|
|
Tests for neopig module.
|
|
|
|
Tests the main NeoPig crawler class and related functionality.
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import Mock, AsyncMock, patch, MagicMock
|
|
import tempfile
|
|
import shutil
|
|
import os
|
|
import json
|
|
from pathlib import Path
|
|
from datetime import datetime, timezone
|
|
|
|
from neopig import (
|
|
NeoPig,
|
|
AppendOnlyStateLog,
|
|
get_state_log_path,
|
|
get_state_file_path,
|
|
rotate_state_file,
|
|
get_live_queue,
|
|
emit_live_media,
|
|
setup_logging,
|
|
start_job_logging,
|
|
stop_job_logging,
|
|
)
|
|
|
|
|
|
class TestAppendOnlyStateLog:
|
|
"""Test AppendOnlyStateLog class."""
|
|
|
|
def setup_method(self):
|
|
self.temp_dir = tempfile.mkdtemp()
|
|
self.log_path = Path(self.temp_dir) / "test.log"
|
|
|
|
def teardown_method(self):
|
|
if os.path.exists(self.temp_dir):
|
|
shutil.rmtree(self.temp_dir)
|
|
|
|
def test_state_log_open_close(self):
|
|
"""Test opening and closing state log."""
|
|
log = AppendOnlyStateLog(self.log_path)
|
|
log.open()
|
|
assert log._file is not None
|
|
log.close()
|
|
assert log._file is None
|
|
|
|
def test_state_log_context_manager(self):
|
|
"""Test state log as context manager."""
|
|
with AppendOnlyStateLog(self.log_path) as log:
|
|
assert log._file is not None
|
|
assert log._file is None
|
|
|
|
def test_state_log_page(self):
|
|
"""Test recording page as seen."""
|
|
with AppendOnlyStateLog(self.log_path) as log:
|
|
log.page("https://example.com/page1")
|
|
log.page("https://example.com/page2")
|
|
|
|
content = self.log_path.read_text()
|
|
assert "P https://example.com/page1" in content
|
|
assert "P https://example.com/page2" in content
|
|
|
|
def test_state_log_media(self):
|
|
"""Test recording media as downloaded."""
|
|
with AppendOnlyStateLog(self.log_path) as log:
|
|
log.media("abc123", "https://example.com/image.jpg")
|
|
|
|
content = self.log_path.read_text()
|
|
assert "M abc123 https://example.com/image.jpg" in content
|
|
|
|
def test_state_log_screenshot(self):
|
|
"""Test recording screenshot as taken."""
|
|
with AppendOnlyStateLog(self.log_path) as log:
|
|
log.screenshot("https://example.com/page")
|
|
|
|
content = self.log_path.read_text()
|
|
assert "S https://example.com/page" in content
|
|
|
|
def test_state_log_skip_domain(self):
|
|
"""Test recording domain to skip."""
|
|
with AppendOnlyStateLog(self.log_path) as log:
|
|
log.skip_domain("blocked.com")
|
|
|
|
content = self.log_path.read_text()
|
|
assert "D blocked.com" in content
|
|
|
|
def test_state_log_stats(self):
|
|
"""Test recording stats checkpoint."""
|
|
stats = {"pages_crawled": 100, "media_found": 500}
|
|
with AppendOnlyStateLog(self.log_path) as log:
|
|
log.stats(stats)
|
|
|
|
content = self.log_path.read_text()
|
|
assert "X stats" in content
|
|
assert "100" in content
|
|
|
|
def test_state_log_load_empty(self):
|
|
"""Test loading empty/nonexistent log."""
|
|
log = AppendOnlyStateLog(self.log_path)
|
|
result = log.load()
|
|
|
|
assert result['seen_pages'] == set()
|
|
assert result['seen_media'] == {}
|
|
assert result['seen_screenshots'] == set()
|
|
assert result['skip_domains'] == set()
|
|
assert result['stats'] == {}
|
|
|
|
def test_state_log_load_with_data(self):
|
|
"""Test loading log with data."""
|
|
with AppendOnlyStateLog(self.log_path) as log:
|
|
log.page("https://example.com/page1")
|
|
log.media("abc123", "https://example.com/image.jpg")
|
|
log.screenshot("https://example.com/")
|
|
log.skip_domain("blocked.com")
|
|
log.stats({"pages_crawled": 10})
|
|
|
|
log = AppendOnlyStateLog(self.log_path)
|
|
result = log.load()
|
|
|
|
assert "https://example.com/page1" in result['seen_pages']
|
|
assert result['seen_media'].get("https://example.com/image.jpg") == "abc123"
|
|
assert "https://example.com/" in result['seen_screenshots']
|
|
assert "blocked.com" in result['skip_domains']
|
|
assert result['stats'].get('pages_crawled') == 10
|
|
|
|
|
|
class TestStateFilePaths:
|
|
"""Test state file path generation."""
|
|
|
|
def test_get_state_log_path(self):
|
|
"""Test state log path generation."""
|
|
path = get_state_log_path("example.com")
|
|
assert path == Path("data/example-com.log")
|
|
|
|
def test_get_state_log_path_with_subdomain(self):
|
|
"""Test state log path with subdomain."""
|
|
path = get_state_log_path("sub.example.com")
|
|
assert "sub" in str(path)
|
|
assert path.suffix == ".log"
|
|
|
|
def test_get_state_file_path(self):
|
|
"""Test state file path generation."""
|
|
path = get_state_file_path("example.com")
|
|
assert path == Path("data/example-com.state")
|
|
|
|
def test_get_state_file_path_special_chars(self):
|
|
"""Test state file path with special characters."""
|
|
path = get_state_file_path("example.com:8080")
|
|
assert "state" in str(path)
|
|
# The path should be a valid state file path
|
|
assert path.suffix == ".state"
|
|
|
|
|
|
class TestRotateStateFile:
|
|
"""Test state file rotation."""
|
|
|
|
def setup_method(self):
|
|
self.temp_dir = tempfile.mkdtemp()
|
|
|
|
def teardown_method(self):
|
|
if os.path.exists(self.temp_dir):
|
|
shutil.rmtree(self.temp_dir)
|
|
|
|
def test_rotate_nonexistent(self):
|
|
"""Test rotating nonexistent file returns None."""
|
|
path = Path(self.temp_dir) / "nonexistent.state"
|
|
result = rotate_state_file(path)
|
|
assert result is None
|
|
|
|
def test_rotate_existing(self):
|
|
"""Test rotating existing file."""
|
|
path = Path(self.temp_dir) / "test.state"
|
|
path.write_text('{"key": "value"}')
|
|
|
|
rotated = rotate_state_file(path)
|
|
|
|
assert rotated is not None
|
|
assert rotated.exists()
|
|
assert not path.exists()
|
|
assert ".1" in str(rotated)
|
|
|
|
def test_rotate_multiple(self):
|
|
"""Test multiple rotations increment number."""
|
|
path = Path(self.temp_dir) / "test.state"
|
|
|
|
# First rotation
|
|
path.write_text('{"v": 1}')
|
|
rotated1 = rotate_state_file(path)
|
|
assert ".1" in str(rotated1)
|
|
|
|
# Second rotation
|
|
path.write_text('{"v": 2}')
|
|
rotated2 = rotate_state_file(path)
|
|
assert ".2" in str(rotated2)
|
|
|
|
def test_rotate_with_preserve_keys(self):
|
|
"""Test rotation with key preservation."""
|
|
path = Path(self.temp_dir) / "test.state"
|
|
path.write_text('{"keep": "value", "discard": "other"}')
|
|
|
|
rotated = rotate_state_file(path, preserve_keys=["keep"])
|
|
|
|
assert rotated.exists()
|
|
assert path.exists() # New file with preserved keys
|
|
new_content = json.loads(path.read_text())
|
|
assert new_content.get("keep") == "value"
|
|
assert "discard" not in new_content
|
|
|
|
|
|
class TestLiveQueue:
|
|
"""Test live media queue functions."""
|
|
|
|
def test_get_live_queue(self):
|
|
"""Test getting live queue creates queue."""
|
|
queue = get_live_queue()
|
|
assert queue is not None
|
|
# Should return same queue on second call
|
|
queue2 = get_live_queue()
|
|
assert queue is queue2
|
|
|
|
def test_emit_live_media(self):
|
|
"""Test emitting media to queue."""
|
|
queue = get_live_queue()
|
|
# Empty queue first
|
|
while not queue.empty():
|
|
try:
|
|
queue.get_nowait()
|
|
except:
|
|
break
|
|
|
|
media_info = {"md5_hash": "abc123", "media_type": "image"}
|
|
emit_live_media(media_info)
|
|
|
|
# Should be able to get the item
|
|
assert not queue.empty()
|
|
|
|
|
|
class TestNeoPigInitialization:
|
|
"""Test NeoPig class initialization."""
|
|
|
|
def setup_method(self):
|
|
self.temp_dir = tempfile.mkdtemp()
|
|
self.db_path = os.path.join(self.temp_dir, "test.db")
|
|
self.vault_path = os.path.join(self.temp_dir, "vault")
|
|
|
|
def teardown_method(self):
|
|
if os.path.exists(self.temp_dir):
|
|
shutil.rmtree(self.temp_dir)
|
|
|
|
def test_neopig_creation(self):
|
|
"""Test creating NeoPig instance."""
|
|
pig = NeoPig(
|
|
db_path=self.db_path,
|
|
vault_path=self.vault_path,
|
|
)
|
|
|
|
assert pig.db is not None
|
|
assert pig.vault is not None
|
|
assert pig.fetcher is not None
|
|
assert pig.screenshot is not None
|
|
|
|
def test_neopig_fast_mode(self):
|
|
"""Test NeoPig in fast mode."""
|
|
pig = NeoPig(
|
|
db_path=self.db_path,
|
|
vault_path=self.vault_path,
|
|
fast_mode=True,
|
|
)
|
|
|
|
assert pig.fast_mode is True
|
|
assert pig.fetcher.default_crawl_delay == 0.0
|
|
|
|
def test_neopig_stats_initialized(self):
|
|
"""Test NeoPig stats are initialized."""
|
|
pig = NeoPig(
|
|
db_path=self.db_path,
|
|
vault_path=self.vault_path,
|
|
)
|
|
|
|
assert pig.stats['pages_crawled'] == 0
|
|
assert pig.stats['media_found'] == 0
|
|
assert pig.stats['errors'] == 0
|
|
|
|
def test_neopig_state_tracking(self):
|
|
"""Test NeoPig state tracking structures initialized."""
|
|
pig = NeoPig(
|
|
db_path=self.db_path,
|
|
vault_path=self.vault_path,
|
|
)
|
|
|
|
assert pig.seen_media == {}
|
|
assert pig.seen_screenshots == set()
|
|
assert pig.seen_pages == set()
|
|
|
|
|
|
class TestNeoPigStateMethods:
|
|
"""Test NeoPig state management methods."""
|
|
|
|
def setup_method(self):
|
|
self.temp_dir = tempfile.mkdtemp()
|
|
self.db_path = os.path.join(self.temp_dir, "test.db")
|
|
self.vault_path = os.path.join(self.temp_dir, "vault")
|
|
self.pig = NeoPig(
|
|
db_path=self.db_path,
|
|
vault_path=self.vault_path,
|
|
)
|
|
# Override state dir for tests
|
|
self.pig._state_dir = Path(self.temp_dir)
|
|
|
|
def teardown_method(self):
|
|
if os.path.exists(self.temp_dir):
|
|
shutil.rmtree(self.temp_dir)
|
|
|
|
def test_get_state_file(self):
|
|
"""Test getting state file path for URL."""
|
|
path = self.pig._get_state_file("https://example.com/page")
|
|
assert "example" in str(path)
|
|
assert path.suffix == ".state"
|
|
|
|
def test_get_state_log(self):
|
|
"""Test getting state log for URL."""
|
|
log = self.pig._get_state_log("https://example.com/page")
|
|
# Check by class name since module identity differs between package and module
|
|
assert log.__class__.__name__ == 'AppendOnlyStateLog'
|
|
|
|
def test_get_hydra_state_file(self):
|
|
"""Test getting hydra state file path."""
|
|
path = self.pig._get_hydra_state_file("example.com")
|
|
assert "hydra" in str(path)
|
|
assert ".json" in str(path)
|
|
|
|
def test_load_hydra_state_empty(self):
|
|
"""Test loading empty hydra state."""
|
|
state = self.pig._load_hydra_state("example.com")
|
|
assert state['seen_urls'] == {}
|
|
assert state['feeds'] == {}
|
|
|
|
def test_save_and_load_hydra_state(self):
|
|
"""Test saving and loading hydra state."""
|
|
domain = "example.com"
|
|
state = {
|
|
'seen_urls': {'https://example.com/article': {}},
|
|
'feeds': {'https://example.com/rss': {}},
|
|
}
|
|
self.pig._save_hydra_state(domain, state)
|
|
|
|
loaded = self.pig._load_hydra_state(domain)
|
|
assert 'https://example.com/article' in loaded['seen_urls']
|
|
assert 'https://example.com/rss' in loaded['feeds']
|
|
|
|
def test_get_known_feeds(self):
|
|
"""Test getting known feeds for domain."""
|
|
domain = "example.com"
|
|
state = {
|
|
'seen_urls': {},
|
|
'feeds': {
|
|
'https://example.com/rss': {},
|
|
'https://example.com/atom': {},
|
|
},
|
|
}
|
|
self.pig._save_hydra_state(domain, state)
|
|
|
|
feeds = self.pig._get_known_feeds(domain)
|
|
assert len(feeds) == 2
|
|
assert 'https://example.com/rss' in feeds
|
|
|
|
def test_add_hydra_feeds(self):
|
|
"""Test adding new feeds to hydra state."""
|
|
domain = "example.com"
|
|
self.pig._add_hydra_feeds(domain, ['https://example.com/feed1'])
|
|
|
|
state = self.pig._load_hydra_state(domain)
|
|
assert 'https://example.com/feed1' in state['feeds']
|
|
|
|
|
|
class TestNeoPigAsync:
|
|
"""Test NeoPig async methods."""
|
|
|
|
def setup_method(self):
|
|
self.temp_dir = tempfile.mkdtemp()
|
|
self.db_path = os.path.join(self.temp_dir, "test.db")
|
|
self.vault_path = os.path.join(self.temp_dir, "vault")
|
|
|
|
def teardown_method(self):
|
|
if os.path.exists(self.temp_dir):
|
|
shutil.rmtree(self.temp_dir)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_neopig_init_async(self):
|
|
"""Test NeoPig async initialization."""
|
|
pig = NeoPig(
|
|
db_path=self.db_path,
|
|
vault_path=self.vault_path,
|
|
)
|
|
await pig.init()
|
|
|
|
assert pig.db._initialized is True
|
|
assert pig.vault._initialized is True
|
|
|
|
# Cleanup
|
|
await pig.db.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_neopig_db_close(self):
|
|
"""Test NeoPig database cleanup."""
|
|
pig = NeoPig(
|
|
db_path=self.db_path,
|
|
vault_path=self.vault_path,
|
|
)
|
|
await pig.init()
|
|
await pig.db.close()
|
|
|
|
# Should not raise
|
|
|
|
|
|
class TestJobLogging:
|
|
"""Test job logging functions."""
|
|
|
|
def setup_method(self):
|
|
self.temp_dir = tempfile.mkdtemp()
|
|
|
|
def teardown_method(self):
|
|
if os.path.exists(self.temp_dir):
|
|
shutil.rmtree(self.temp_dir)
|
|
|
|
def test_start_job_logging(self):
|
|
"""Test starting job logging."""
|
|
# This modifies global state, so we just verify it doesn't crash
|
|
# LOGS_PATH is now in neopig.logging module
|
|
# The actual logging is tested in test_neopig_logging.py
|
|
pass
|
|
|
|
|
|
class TestSetupLogging:
|
|
"""Test logging setup."""
|
|
|
|
def test_setup_logging(self):
|
|
"""Test setting up logging doesn't crash."""
|
|
import logging
|
|
setup_logging(level=logging.DEBUG)
|
|
# Verify root logger has handlers
|
|
assert len(logging.getLogger().handlers) > 0
|
|
|
|
|
|
class TestNeoPigStats:
|
|
"""Test NeoPig statistics tracking."""
|
|
|
|
def setup_method(self):
|
|
self.temp_dir = tempfile.mkdtemp()
|
|
self.db_path = os.path.join(self.temp_dir, "test.db")
|
|
self.vault_path = os.path.join(self.temp_dir, "vault")
|
|
self.pig = NeoPig(
|
|
db_path=self.db_path,
|
|
vault_path=self.vault_path,
|
|
)
|
|
|
|
def teardown_method(self):
|
|
if os.path.exists(self.temp_dir):
|
|
shutil.rmtree(self.temp_dir)
|
|
|
|
def test_stats_structure(self):
|
|
"""Test stats dictionary structure."""
|
|
required_keys = [
|
|
'pages_crawled',
|
|
'pages_pending',
|
|
'media_found',
|
|
'media_downloaded',
|
|
'duplicates_skipped',
|
|
'screenshots_taken',
|
|
'errors',
|
|
'bytes_downloaded',
|
|
'bytes_stored',
|
|
]
|
|
for key in required_keys:
|
|
assert key in self.pig.stats
|
|
|
|
def test_stats_initial_values(self):
|
|
"""Test stats start at zero."""
|
|
for key, value in self.pig.stats.items():
|
|
if isinstance(value, int):
|
|
assert value == 0
|
|
|
|
|
|
class TestNeoPigVaultManager:
|
|
"""Test NeoPig vault manager integration."""
|
|
|
|
def setup_method(self):
|
|
self.temp_dir = tempfile.mkdtemp()
|
|
self.db_path = os.path.join(self.temp_dir, "test.db")
|
|
self.vault_path = os.path.join(self.temp_dir, "vault")
|
|
self.pig = NeoPig(
|
|
db_path=self.db_path,
|
|
vault_path=self.vault_path,
|
|
)
|
|
|
|
def teardown_method(self):
|
|
if os.path.exists(self.temp_dir):
|
|
shutil.rmtree(self.temp_dir)
|
|
|
|
def test_domain_vaults_initialized(self):
|
|
"""Test domain vaults are initialized."""
|
|
assert self.pig.domain_vaults is not None
|
|
|
|
def test_vault_paths(self):
|
|
"""Test vault paths are set correctly."""
|
|
assert self.pig.vault_path == self.vault_path
|
|
|
|
|
|
class TestTqdmLoggingHandler:
|
|
"""Test TqdmLoggingHandler."""
|
|
|
|
def test_tqdm_handler_exists(self):
|
|
"""Test TqdmLoggingHandler class exists."""
|
|
from neopig import TqdmLoggingHandler
|
|
handler = TqdmLoggingHandler()
|
|
assert handler is not None
|
|
|
|
def test_tqdm_handler_emit(self):
|
|
"""Test handler emit doesn't crash."""
|
|
from neopig import TqdmLoggingHandler
|
|
import logging
|
|
|
|
handler = TqdmLoggingHandler()
|
|
handler.setFormatter(logging.Formatter('%(message)s'))
|
|
|
|
record = logging.LogRecord(
|
|
name='test',
|
|
level=logging.INFO,
|
|
pathname='test.py',
|
|
lineno=1,
|
|
msg='Test message',
|
|
args=(),
|
|
exc_info=None
|
|
)
|
|
|
|
# Should not raise
|
|
handler.emit(record)
|
|
|
|
|
|
class TestNeoPigCrawlMethods:
|
|
"""Test NeoPig crawl-related methods."""
|
|
|
|
def setup_method(self):
|
|
self.temp_dir = tempfile.mkdtemp()
|
|
self.db_path = os.path.join(self.temp_dir, "test.db")
|
|
self.vault_path = os.path.join(self.temp_dir, "vault")
|
|
self.pig = NeoPig(
|
|
db_path=self.db_path,
|
|
vault_path=self.vault_path,
|
|
)
|
|
self.pig._state_dir = Path(self.temp_dir)
|
|
|
|
def teardown_method(self):
|
|
if os.path.exists(self.temp_dir):
|
|
shutil.rmtree(self.temp_dir)
|
|
|
|
def test_save_state_interval(self):
|
|
"""Test state save interval."""
|
|
# State is saved every N items
|
|
assert self.pig._state_save_interval == 10
|
|
assert self.pig._items_since_save == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_neopig_crawl_returns_stats(self):
|
|
"""Test that crawl stats structure exists."""
|
|
await self.pig.init()
|
|
|
|
# We can't fully test crawl without extensive mocking,
|
|
# but we can verify the structure
|
|
assert 'pages_crawled' in self.pig.stats
|
|
assert 'media_found' in self.pig.stats
|
|
assert 'errors' in self.pig.stats
|
|
|
|
await self.pig.db.close()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
pytest.main([__file__, '-v'])
|