146 lines
6.1 KiB
Python
146 lines
6.1 KiB
Python
"""
|
|
analysis_service orchestrates the fast/refresh path decision (CLAUDE.md
|
|
§7). The refresh path lazy-imports Phase 2 modules (core.scraper,
|
|
core.extractor, industries.adventure_travel.prompt) that don't exist
|
|
yet — _install_fake_module injects stand-ins into sys.modules so the
|
|
orchestration logic itself (not the scraping/extraction internals) can
|
|
be tested now.
|
|
"""
|
|
import sys
|
|
import types
|
|
from datetime import datetime, timedelta, timezone
|
|
import pytest
|
|
from services import analysis_service
|
|
from repositories.competitor_repository import competitor_repository
|
|
from repositories.product_repository import product_repository
|
|
from repositories.tenant_repository import tenant_repository
|
|
|
|
|
|
def _install_fake_module(monkeypatch, dotted_name, **attrs):
|
|
"""Injects a fake module into sys.modules, creating parent packages as needed."""
|
|
parts = dotted_name.split('.')
|
|
for i in range(1, len(parts) + 1):
|
|
partial = '.'.join(parts[:i])
|
|
if partial not in sys.modules:
|
|
monkeypatch.setitem(sys.modules, partial, types.ModuleType(partial))
|
|
target = sys.modules[dotted_name]
|
|
for key, value in attrs.items():
|
|
setattr(target, key, value)
|
|
return target
|
|
|
|
|
|
def _make_tenant_and_competitor(**competitor_overrides):
|
|
tenant = tenant_repository.create({
|
|
'name': 'G Adventures', 'domain': 'gadventures.com', 'tier': 'analyse',
|
|
'status': 'active', 'max_seats': 5,
|
|
})
|
|
data = {
|
|
'tenant_id': tenant['id'], 'name': 'Intrepid Travel', 'website': 'https://intrepidtravel.com',
|
|
'catalogue_url': 'https://intrepidtravel.com/adventures', 'primary_market': 'GB', 'active': True,
|
|
}
|
|
data.update(competitor_overrides)
|
|
competitor = competitor_repository.create(data)
|
|
return tenant, competitor
|
|
|
|
|
|
def test_needs_refresh_true_when_change_detected():
|
|
assert analysis_service.needs_refresh({'change_detected': True, 'last_scraped': None}) is True
|
|
|
|
|
|
def test_needs_refresh_true_when_never_scraped():
|
|
assert analysis_service.needs_refresh({'change_detected': False, 'last_scraped': None}) is True
|
|
|
|
|
|
def test_needs_refresh_true_when_stale():
|
|
stale = (datetime.now(timezone.utc) - timedelta(hours=48)).isoformat()
|
|
assert analysis_service.needs_refresh({'change_detected': False, 'last_scraped': stale}) is True
|
|
|
|
|
|
def test_needs_refresh_false_when_fresh_and_unchanged():
|
|
fresh = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat()
|
|
assert analysis_service.needs_refresh({'change_detected': False, 'last_scraped': fresh}) is False
|
|
|
|
|
|
def test_needs_refresh_true_on_malformed_timestamp():
|
|
assert analysis_service.needs_refresh({'change_detected': False, 'last_scraped': 'not-a-date'}) is True
|
|
|
|
|
|
def test_analyse_from_cache_raises_without_cached_products():
|
|
_, competitor = _make_tenant_and_competitor()
|
|
with pytest.raises(ValueError):
|
|
analysis_service.analyse_from_cache('t1', competitor['id'], {})
|
|
|
|
|
|
def test_analyse_from_cache_uses_latest_product(monkeypatch):
|
|
tenant, competitor = _make_tenant_and_competitor()
|
|
product_repository.create({
|
|
'tenant_id': tenant['id'], 'competitor_id': competitor['id'],
|
|
'trip_name': 'Peru Classic', 'last_seen': '2026-01-01',
|
|
})
|
|
captured = {}
|
|
_install_fake_module(
|
|
monkeypatch, 'core.extractor',
|
|
extract_with_openrouter=lambda prompt, content: captured.update(prompt=prompt) or {'narrative': 'ok'}
|
|
)
|
|
|
|
result = analysis_service.analyse_from_cache(tenant['id'], competitor['id'], {'destination': 'Peru'})
|
|
|
|
assert result == {'narrative': 'ok'}
|
|
assert 'Peru Classic' in captured['prompt']
|
|
|
|
|
|
def test_run_competitor_analysis_takes_fast_path_when_fresh(monkeypatch):
|
|
fresh = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
|
|
tenant, competitor = _make_tenant_and_competitor(change_detected=False, last_scraped=fresh)
|
|
product_repository.create({
|
|
'tenant_id': tenant['id'], 'competitor_id': competitor['id'], 'trip_name': 'Peru Classic',
|
|
})
|
|
_install_fake_module(monkeypatch, 'core.extractor', extract_with_openrouter=lambda p, content: {'ok': True})
|
|
|
|
result = analysis_service.run_competitor_analysis(tenant['id'], competitor, {})
|
|
|
|
assert result['path'] == 'fast'
|
|
|
|
|
|
def test_run_competitor_analysis_takes_refresh_path_and_persists_product(monkeypatch):
|
|
tenant, competitor = _make_tenant_and_competitor(change_detected=True)
|
|
|
|
fake_page_data = {'success': True, 'url': competitor['website'], 'text': 'scraped content'}
|
|
_install_fake_module(monkeypatch, 'core.scraper', scrape=lambda url, country=None: fake_page_data)
|
|
_install_fake_module(
|
|
monkeypatch, 'core.extractor',
|
|
extract_with_openrouter=lambda prompt, content: {
|
|
'tripName': 'Peru Classic', 'duration': 15, 'majorityPrice': 2190,
|
|
'priceLow': 2050, 'priceHigh': 2300, 'comments': 'Solid value', 'relevancy': 4,
|
|
}
|
|
)
|
|
_install_fake_module(
|
|
monkeypatch, 'industries.adventure_travel.prompt',
|
|
build_prompt=lambda *a, **k: 'a built prompt'
|
|
)
|
|
|
|
result = analysis_service.run_competitor_analysis(tenant['id'], competitor, {
|
|
'destination': 'Peru', 'duration': 15, 'travel_style': 'Classic',
|
|
'product_name': 'Inca Trail', 'tab_type': 'Standard',
|
|
})
|
|
|
|
assert result['path'] == 'refresh'
|
|
assert result['product']['trip_name'] == 'Peru Classic'
|
|
|
|
updated_competitor = competitor_repository.get_by_id(competitor['id'])
|
|
assert updated_competitor['change_detected'] is False
|
|
assert updated_competitor['last_scraped'] is not None
|
|
|
|
|
|
def test_run_competitor_analysis_refresh_path_raises_on_scrape_failure(monkeypatch):
|
|
tenant, competitor = _make_tenant_and_competitor(change_detected=True)
|
|
_install_fake_module(
|
|
monkeypatch, 'core.scraper',
|
|
scrape=lambda url, country=None: {'success': False, 'text': 'scrape_blocked'}
|
|
)
|
|
_install_fake_module(monkeypatch, 'core.extractor', extract_with_openrouter=lambda p, content: {})
|
|
_install_fake_module(monkeypatch, 'industries.adventure_travel.prompt', build_prompt=lambda *a, **k: '')
|
|
|
|
with pytest.raises(ValueError):
|
|
analysis_service.run_competitor_analysis(tenant['id'], competitor, {})
|