mirror of
https://github.com/R0m1k3/Priceflow.git
synced 2026-10-20 05:38:12 +02:00
Add files via upload
This commit is contained in:
72 files changed
+12339
No files matched your search
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in new issue
Block a user