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

154 lines
6.5 KiB
Python

"""
analysis_service orchestrates the fast/refresh path decision (CLAUDE.md
§7). It calls into core.scraper / core.extractor /
industries.adventure_travel.prompt, all of which are now real Phase 2
modules — _install_fake_module swaps out just the functions this test
needs via monkeypatch.setattr (properly reverted after each test),
rather than mutating the shared module objects directly.
"""
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):
"""
Ensures `dotted_name` resolves to a module (creating fake parent
packages only if truly absent), then uses monkeypatch.setattr for
every attribute — critical when the target is a REAL module (as
core.scraper/core.extractor/industries.adventure_travel.prompt all
are post-Phase-2): monkeypatch.setattr records the original value
and restores it on teardown, whereas a raw setattr would silently
and permanently corrupt the shared module for every later test.
"""
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():
monkeypatch.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, {})