Add files via upload

This commit is contained in:
LogiFlow authored and GitHub committed 2025-11-22 10:01:25 +01:00
commit afed10abc5
72 files changed
+12339

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
import os
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
# Set env var before importing app.database
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
from app.database import Base, get_db
from app.main import app
# Use in-memory SQLite for testing
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@pytest.fixture(scope="function")
def db():
# Create tables
Base.metadata.create_all(bind=engine)
session = TestingSessionLocal()
try:
yield session
finally:
session.close()
# Drop tables
Base.metadata.drop_all(bind=engine)
@pytest.fixture(scope="function")
def client(db):
def override_get_db():
try:
yield db
finally:
pass
app.dependency_overrides[get_db] = override_get_db
# Mock scheduler to prevent event loop issues
# Mock SessionLocal to return the test session
# We need a factory that returns the session, but SessionLocal() creates a new session.
# So we mock SessionLocal to return a mock that acts like a session but is actually our test session.
# However, our test session is scoped to the function.
# A better approach is to use a separate engine for the background task or share the connection.
# Since we use SQLite in-memory, sharing connection is key.
# But process_item_check creates a NEW session.
# Let's mock SessionLocal to return a session bound to the SAME engine.
# But we are using StaticPool, so all sessions share the same connection.
# So we just need SessionLocal to return a session from TestingSessionLocal.
with (
patch("app.main.scheduler.start"),
patch("app.main.scheduler.shutdown"),
patch("app.database.SessionLocal", side_effect=TestingSessionLocal),
):
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
+221
View File
@@ -0,0 +1,221 @@
"""
Unit tests for AI schema validation and normalization.
"""
import pytest
from pydantic import ValidationError
from app.ai_schema import AIExtractionMetadata, AIExtractionResponse, get_extraction_prompt, get_repair_prompt
class TestAIExtractionResponse:
"""Test the AI extraction response schema."""
def test_valid_response(self):
"""Test that valid data passes validation."""
data = {
"price": 99.99,
"currency": "USD",
"in_stock": True,
"price_confidence": 0.95,
"in_stock_confidence": 0.90,
"source_type": "image",
}
response = AIExtractionResponse(**data)
assert response.price == 99.99
assert response.in_stock is True
assert response.price_confidence == 0.95
assert response.in_stock_confidence == 0.90
def test_null_price(self):
"""Test that null price is handled correctly."""
data = {
"price": None,
"in_stock": False,
"price_confidence": 0.2,
"in_stock_confidence": 0.8,
}
response = AIExtractionResponse(**data)
assert response.price is None
assert response.in_stock is False
def test_confidence_clamping_above(self):
"""Test that confidence values above 1.0 are clamped."""
data = {
"price": 50.0,
"in_stock": True,
"price_confidence": 1.5, # Should clamp to 1.0
"in_stock_confidence": 2.0, # Should clamp to 1.0
}
response = AIExtractionResponse(**data)
assert response.price_confidence == 1.0
assert response.in_stock_confidence == 1.0
def test_confidence_clamping_below(self):
"""Test that confidence values below 0.0 are clamped."""
data = {
"price": 50.0,
"in_stock": True,
"price_confidence": -0.5, # Should clamp to 0.0
"in_stock_confidence": -1.0, # Should clamp to 0.0
}
response = AIExtractionResponse(**data)
assert response.price_confidence == 0.0
assert response.in_stock_confidence == 0.0
def test_price_string_normalization(self):
"""Test that price strings are normalized to floats."""
data = {
"price": "$99.99",
"in_stock": True,
"price_confidence": 0.8,
"in_stock_confidence": 0.8,
}
response = AIExtractionResponse(**data)
assert response.price == 99.99
def test_price_string_with_commas(self):
"""Test that prices with commas are handled."""
data = {
"price": "$1,234.56",
"in_stock": True,
"price_confidence": 0.9,
"in_stock_confidence": 0.9,
}
response = AIExtractionResponse(**data)
assert response.price == 1234.56
def test_stock_string_normalization_true(self):
"""Test that stock status strings are normalized to booleans."""
test_cases = ["true", "True", "yes", "in stock", "available", "1"]
for value in test_cases:
data = {
"price": 10.0,
"in_stock": value,
"price_confidence": 0.8,
"in_stock_confidence": 0.8,
}
response = AIExtractionResponse(**data)
assert response.in_stock is True, f"Failed for value: {value}"
def test_stock_string_normalization_false(self):
"""Test that out-of-stock strings are normalized to False."""
test_cases = ["false", "False", "no", "out of stock", "unavailable", "0"]
for value in test_cases:
data = {
"price": 10.0,
"in_stock": value,
"price_confidence": 0.8,
"in_stock_confidence": 0.8,
}
response = AIExtractionResponse(**data)
assert response.in_stock is False, f"Failed for value: {value}"
def test_stock_ambiguous_string(self):
"""Test that ambiguous stock strings return None."""
data = {
"price": 10.0,
"in_stock": "maybe",
"price_confidence": 0.8,
"in_stock_confidence": 0.3,
}
response = AIExtractionResponse(**data)
assert response.in_stock is None
def test_default_currency(self):
"""Test that default currency is USD."""
data = {
"price": 10.0,
"in_stock": True,
"price_confidence": 0.8,
"in_stock_confidence": 0.8,
}
response = AIExtractionResponse(**data)
assert response.currency == "USD"
def test_default_source_type(self):
"""Test that default source type is 'image'."""
data = {
"price": 10.0,
"in_stock": True,
"price_confidence": 0.8,
"in_stock_confidence": 0.8,
}
response = AIExtractionResponse(**data)
assert response.source_type == "image"
def test_source_type_validation(self):
"""Test that source_type must be one of: image, text, both."""
with pytest.raises(ValidationError):
AIExtractionResponse(
price=10.0,
in_stock=True,
price_confidence=0.8,
in_stock_confidence=0.8,
source_type="invalid", # Should fail
)
class TestAIExtractionMetadata:
"""Test the AI extraction metadata schema."""
def test_valid_metadata(self):
"""Test that valid metadata passes validation."""
data = {
"model_name": "gpt-4o",
"provider": "openai",
"prompt_version": "v2.0",
"repair_used": False,
"multi_sample": False,
"sample_count": 1,
}
metadata = AIExtractionMetadata(**data)
assert metadata.model_name == "gpt-4o"
assert metadata.provider == "openai"
assert metadata.repair_used is False
def test_default_values(self):
"""Test that default values are set correctly."""
data = {
"model_name": "gemma3:4b",
"provider": "ollama",
}
metadata = AIExtractionMetadata(**data)
assert metadata.repair_used is False
assert metadata.multi_sample is False
assert metadata.sample_count == 1
class TestPromptGeneration:
"""Test prompt generation functions."""
def test_extraction_prompt_without_text(self):
"""Test extraction prompt without text context."""
prompt = get_extraction_prompt(None)
assert "Extract product price" in prompt
assert "JSON" in prompt
assert "price_confidence" in prompt
assert "**Relevant text from page:**" not in prompt
def test_extraction_prompt_with_text(self):
"""Test extraction prompt with text context."""
page_text = "Product is in stock for $99.99"
prompt = get_extraction_prompt(page_text)
assert "Extract product price" in prompt
assert "**Relevant text from page:**" in prompt
assert "$99.99" in prompt
def test_extraction_prompt_with_long_text(self):
"""Test that long text is truncated."""
page_text = "A" * 5000
prompt = get_extraction_prompt(page_text)
assert "**Relevant text from page:**" in prompt
assert "truncated" in prompt
def test_repair_prompt(self):
"""Test repair prompt generation."""
raw_output = "This is malformed JSON { price: 99.99 }"
prompt = get_repair_prompt(raw_output)
assert "Convert the following text" in prompt
assert raw_output in prompt
assert "JSON" in prompt
+109
View File
@@ -0,0 +1,109 @@
# Since we are using sync TestClient with async endpoints, FastAPI handles the loop.
# However, if we want to test async functions directly, we need pytest-asyncio.
# For API tests, TestClient is fine as it wraps the app.
def test_read_main(client):
response = client.get("/api/")
assert response.status_code == 200
assert response.json() == {"message": "Welcome to Pricecious API"}
def test_create_notification_profile(client):
response = client.post(
"/api/notification-profiles",
json={
"name": "Test Profile",
"apprise_url": "mailto://test@example.com",
"notify_on_price_drop": True,
"notify_on_target_price": True,
"price_drop_threshold_percent": 10.0,
"notify_on_stock_change": True,
"check_interval_minutes": 60,
},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Test Profile"
assert "id" in data
def test_update_notification_profile(client):
# Create a profile first
response = client.post(
"/api/notification-profiles",
json={
"name": "Update Test Profile",
"apprise_url": "mailto://test@example.com",
},
)
profile_id = response.json()["id"]
# Update the profile
response = client.put(
f"/api/notification-profiles/{profile_id}",
json={
"name": "Updated Profile Name",
"apprise_url": "mailto://updated@example.com",
"notify_on_price_drop": False,
},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Updated Profile Name"
assert data["apprise_url"] == "mailto://updated@example.com"
assert data["notify_on_price_drop"] is False
assert data["id"] == profile_id
def test_create_item(client):
# First create a profile
profile_response = client.post(
"/api/notification-profiles",
json={"name": "Test Profile 2", "apprise_url": "mailto://test@example.com"},
)
profile_id = profile_response.json()["id"]
response = client.post(
"/api/items",
json={
"url": "https://example.com/product",
"name": "Test Product",
"target_price": 100.0,
"notification_profile_id": profile_id,
},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Test Product"
assert data["url"] == "https://example.com/product"
assert data["notification_profile_id"] == profile_id
def test_get_settings(client):
response = client.get("/api/settings")
assert response.status_code == 200
assert isinstance(response.json(), list)
def test_update_setting(client):
response = client.post("/api/settings", json={"key": "test_key", "value": "test_value"})
assert response.status_code == 200
data = response.json()
assert data["key"] == "test_key"
assert data["value"] == "test_value"
# Add test for check_item to ensure background task triggering works
# Note: This doesn't verify the background task execution, just the endpoint
def test_check_item_trigger(client):
# Create item first
response = client.post(
"/api/items",
json={"url": "https://example.com/check", "name": "Check Product", "target_price": 100.0},
)
item_id = response.json()["id"]
response = client.post(f"/api/items/{item_id}/check")
assert response.status_code == 200
assert response.json() == {"message": "Check triggered"}
+39
View File
@@ -0,0 +1,39 @@
from unittest.mock import patch
from app import models
def test_refresh_all_sets_status(client, db):
# Create a profile
profile = models.NotificationProfile(name="Test Profile", apprise_url="mailto://test@example.com")
db.add(profile)
db.commit()
db.refresh(profile)
# Create items
item1 = models.Item(
url="https://example.com/1", name="Item 1", notification_profile_id=profile.id, is_refreshing=False
)
item2 = models.Item(
url="https://example.com/2", name="Item 2", notification_profile_id=profile.id, is_refreshing=False
)
db.add(item1)
db.add(item2)
db.commit()
# Mock process_item_check to prevent actual execution
with patch("app.routers.jobs.process_item_check") as mock_process:
response = client.post("/api/jobs/refresh-all")
assert response.status_code == 200
assert response.json()["message"] == "Triggered refresh for 2 items"
# Verify mock was called
assert mock_process.call_count == 2
# Verify items are marked as refreshing in DB
# We need to refresh the objects from the DB
db.refresh(item1)
db.refresh(item2)
assert item1.is_refreshing is True
assert item2.is_refreshing is True
+60
View File
@@ -0,0 +1,60 @@
from datetime import UTC, datetime, timedelta
from unittest.mock import patch
import pytest
from app import models
from app.services.scheduler_service import process_item_check
@pytest.mark.asyncio
async def test_process_item_check_updates_last_checked_on_error(db):
# Create a profile
profile = models.NotificationProfile(name="Test Profile", apprise_url="mailto://test@example.com")
db.add(profile)
db.commit()
db.refresh(profile)
# Create item
item = models.Item(
url="https://example.com/error",
name="Error Item",
notification_profile_id=profile.id,
is_refreshing=True,
selector="body",
)
db.add(item)
db.commit()
db.refresh(item)
item_id = item.id
initial_last_checked = item.last_checked
# Create a session maker bound to the same engine as the test db
from sqlalchemy.orm import sessionmaker
connection = db.get_bind()
TestSession = sessionmaker(bind=connection)
# Mock ScraperService.scrape_item to raise an exception
# And patch SessionLocal to return our test session
with (
patch("app.services.scraper_service.ScraperService.scrape_item", side_effect=Exception("Scraper failed")),
patch("app.database.SessionLocal", side_effect=TestSession),
):
await process_item_check(item_id)
# Refresh item from DB
# We need to expire the object to reload from DB because the other session updated it
db.expire(item)
db.refresh(item)
# Assertions
assert item.is_refreshing is False
assert item.last_error == "Scraper failed"
assert item.last_checked is not None
if initial_last_checked:
assert item.last_checked > initial_last_checked
# Ensure last_checked is recent (within last minute)
assert datetime.now(UTC) - item.last_checked.replace(tzinfo=UTC) < timedelta(minutes=1)
+129
View File
@@ -0,0 +1,129 @@
"""
Unit tests for scraper module.
"""
from unittest.mock import AsyncMock, patch
import pytest
from app.services.scraper_service import ScraperService
class TestScraperInputValidation:
"""Test input validation in scraper."""
@pytest.mark.asyncio
async def test_invalid_scroll_pixels(self, caplog):
"""Test that invalid scroll_pixels is corrected."""
with patch("app.services.scraper_service.async_playwright") as mock_playwright:
# Mock the playwright context
mock_browser = AsyncMock()
mock_context = AsyncMock()
mock_page = AsyncMock()
mock_page.goto = AsyncMock()
mock_page.screenshot = AsyncMock()
mock_context.new_page = AsyncMock(return_value=mock_page)
mock_browser.new_context = AsyncMock(return_value=mock_context)
mock_browser.close = AsyncMock()
mock_pw = AsyncMock()
mock_pw.chromium.connect_over_cdp = AsyncMock(return_value=mock_browser)
mock_playwright.return_value.__aenter__.return_value = mock_pw
# Test with negative scroll_pixels
await ScraperService.scrape_item("https://example.com", scroll_pixels=-100)
# Check that warning was logged
assert "Invalid scroll_pixels value" in caplog.text
@pytest.mark.asyncio
async def test_invalid_timeout(self, caplog):
"""Test that invalid timeout is corrected."""
with patch("app.services.scraper_service.async_playwright") as mock_playwright:
mock_browser = AsyncMock()
mock_context = AsyncMock()
mock_page = AsyncMock()
mock_page.goto = AsyncMock()
mock_page.screenshot = AsyncMock()
mock_context.new_page = AsyncMock(return_value=mock_page)
mock_browser.new_context = AsyncMock(return_value=mock_context)
mock_browser.close = AsyncMock()
mock_pw = AsyncMock()
mock_pw.chromium.connect_over_cdp = AsyncMock(return_value=mock_browser)
mock_playwright.return_value.__aenter__.return_value = mock_pw
# Test with zero timeout
await ScraperService.scrape_item("https://example.com", timeout=0)
# Check that warning was logged
assert "Invalid timeout value" in caplog.text
class TestScraperScreenshot:
"""Test screenshot functionality."""
@pytest.mark.asyncio
async def test_screenshot_path_generation(self):
"""Test that screenshot paths are generated correctly."""
with patch("app.services.scraper_service.async_playwright") as mock_playwright:
mock_browser = AsyncMock()
mock_context = AsyncMock()
mock_page = AsyncMock()
mock_page.goto = AsyncMock()
mock_page.screenshot = AsyncMock()
mock_page.inner_text = AsyncMock(return_value="test text")
mock_context.new_page = AsyncMock(return_value=mock_page)
mock_browser.new_context = AsyncMock(return_value=mock_context)
mock_browser.close = AsyncMock()
mock_pw = AsyncMock()
mock_pw.chromium.connect_over_cdp = AsyncMock(return_value=mock_browser)
mock_playwright.return_value.__aenter__.return_value = mock_pw
# Test with item_id
path, _ = await ScraperService.scrape_item("https://example.com", item_id=123)
assert path == "screenshots/item_123.png"
mock_page.screenshot.assert_called_once_with(path="screenshots/item_123.png", full_page=False)
class TestScraperErrorHandling:
"""Test error handling in scraper."""
@pytest.mark.asyncio
async def test_connection_failure(self):
"""Test handling of connection failures."""
with patch("app.services.scraper_service.async_playwright") as mock_playwright:
mock_pw = AsyncMock()
mock_pw.chromium.connect_over_cdp = AsyncMock(side_effect=Exception("Connection failed"))
mock_playwright.return_value.__aenter__.return_value = mock_pw
# Should return None on error
result, text = await ScraperService.scrape_item("https://example.com")
assert result is None
assert text == ""
@pytest.mark.asyncio
async def test_page_load_timeout(self):
"""Test handling of page load timeout."""
with patch("app.services.scraper_service.async_playwright") as mock_playwright:
mock_browser = AsyncMock()
mock_context = AsyncMock()
mock_page = AsyncMock()
mock_page.goto = AsyncMock(side_effect=Exception("Timeout"))
mock_page.screenshot = AsyncMock() # Should still try to screenshot
mock_context.new_page = AsyncMock(return_value=mock_page)
mock_browser.new_context = AsyncMock(return_value=mock_context)
mock_browser.close = AsyncMock()
mock_pw = AsyncMock()
mock_pw.chromium.connect_over_cdp = AsyncMock(return_value=mock_browser)
mock_playwright.return_value.__aenter__.return_value = mock_pw
# Should continue and try to screenshot
await ScraperService.scrape_item("https://example.com", item_id=1)
# Even with timeout, should attempt screenshot
mock_page.screenshot.assert_called_once()
+166
View File
@@ -0,0 +1,166 @@
"""
Tests for URL validation and security.
"""
import pytest
from app.url_validation import URLValidationError, validate_url
class TestURLValidation:
"""Test URL validation for security."""
def test_valid_https_url(self):
"""Test that valid HTTPS URLs pass validation."""
validate_url("https://example.com/product")
# Should not raise
def test_valid_http_url(self):
"""Test that valid HTTP URLs pass validation."""
validate_url("http://example.com/product")
# Should not raise
def test_empty_url(self):
"""Test that empty URLs are rejected."""
with pytest.raises(URLValidationError, match="non-empty string"):
validate_url("")
def test_none_url(self):
"""Test that None URLs are rejected."""
with pytest.raises(URLValidationError, match="non-empty string"):
validate_url(None)
def test_missing_scheme(self):
"""Test that URLs without scheme are rejected."""
with pytest.raises(URLValidationError, match="must include a scheme"):
validate_url("example.com/product")
def test_blocked_scheme_file(self):
"""Test that file:// URLs are blocked."""
with pytest.raises(URLValidationError, match="Blocked URL scheme"):
validate_url("file:///etc/passwd")
def test_blocked_scheme_javascript(self):
"""Test that javascript: URLs are blocked."""
with pytest.raises(URLValidationError, match="Blocked URL scheme"):
validate_url("javascript:alert(1)")
def test_blocked_scheme_data(self):
"""Test that data: URLs are blocked."""
with pytest.raises(URLValidationError, match="Blocked URL scheme"):
validate_url("data:text/html,<script>alert(1)</script>")
def test_blocked_scheme_ftp(self):
"""Test that FTP URLs are blocked."""
with pytest.raises(URLValidationError, match="Blocked URL scheme"):
validate_url("ftp://example.com/file")
def test_localhost_blocked(self):
"""Test that localhost is blocked."""
with pytest.raises(URLValidationError, match="Private/internal IPs"):
validate_url("http://localhost:8000/")
def test_127_0_0_1_blocked(self):
"""Test that 127.0.0.1 is blocked."""
with pytest.raises(URLValidationError, match="Private/internal IPs"):
validate_url("http://127.0.0.1:8000/")
def test_private_ip_10_blocked(self):
"""Test that 10.x.x.x addresses are blocked."""
with pytest.raises(URLValidationError, match="Private/internal IPs"):
validate_url("http://10.0.0.1/")
def test_private_ip_192_blocked(self):
"""Test that 192.168.x.x addresses are blocked."""
with pytest.raises(URLValidationError, match="Private/internal IPs"):
validate_url("http://192.168.1.1/")
def test_private_ip_172_blocked(self):
"""Test that 172.16-31.x.x addresses are blocked."""
with pytest.raises(URLValidationError, match="Private/internal IPs"):
validate_url("http://172.16.0.1/")
def test_link_local_blocked(self):
"""Test that link-local addresses (169.254.x.x) are blocked."""
with pytest.raises(URLValidationError, match="Private/internal IPs"):
validate_url("http://169.254.0.1/")
def test_ipv6_loopback_blocked(self):
"""Test that IPv6 loopback (::1) is blocked."""
with pytest.raises(URLValidationError, match="Private/internal IPs"):
validate_url("http://[::1]:8000/")
def test_allow_private_param(self):
"""Test that allow_private parameter works."""
# Should not raise when allow_private=True
validate_url("http://localhost:8000/", allow_private=True)
validate_url("http://127.0.0.1:8000/", allow_private=True)
validate_url("http://192.168.1.1/", allow_private=True)
def test_missing_hostname(self):
"""Test that URLs without hostname are rejected."""
with pytest.raises(URLValidationError, match="must include a hostname"):
validate_url("http:///path")
def test_url_with_query_params(self):
"""Test that URLs with query parameters work."""
validate_url("https://example.com/product?id=123&ref=abc")
# Should not raise
def test_url_with_fragment(self):
"""Test that URLs with fragments work."""
validate_url("https://example.com/product#section")
# Should not raise
def test_url_with_port(self):
"""Test that URLs with ports work."""
validate_url("https://example.com:8443/product")
# Should not raise
def test_international_domain(self):
"""Test that international domains work."""
validate_url("https://例え.jp/product")
# Should not raise
class TestSSRFPrevention:
"""Test SSRF attack prevention."""
def test_ssrf_localhost_variants(self):
"""Test various localhost representations."""
localhost_variants = [
"http://localhost/",
"http://127.0.0.1/",
"http://127.1/",
"http://0.0.0.0/",
]
for url in localhost_variants:
with pytest.raises(URLValidationError):
validate_url(url)
def test_ssrf_private_networks(self):
"""Test private network ranges."""
private_ranges = [
"http://10.1.2.3/",
"http://172.16.0.1/",
"http://172.31.255.255/",
"http://192.168.0.1/",
"http://169.254.169.254/", # AWS metadata
]
for url in private_ranges:
with pytest.raises(URLValidationError):
validate_url(url)
def test_public_ips_allowed(self):
"""Test that public IPs are allowed."""
public_ips = [
"http://8.8.8.8/",
"http://1.1.1.1/",
"http://142.250.185.78/", # Google
]
for url in public_ips:
validate_url(url)
# Should not raise