pig.py/tests/unit/test_neopig.py

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'])