app.bettersight.io/backend/tests/services/test_analysis_service.py

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, {})