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