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