154 lines
6.5 KiB
Python
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, {})
|