""" 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 imports the real module (Phase 2 guarantees it exists on disk) and swaps out just the functions this test needs via monkeypatch.setattr (properly reverted after each test). """ import importlib 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 from repositories.alert_repository import alert_repository from repositories.price_history_repository import price_history_repository from repositories.scrape_repository import scrape_repository def _install_fake_module(monkeypatch, dotted_name, **attrs): """ Imports the real module at `dotted_name` (every module this helper is ever called with — core.scraper, core.extractor, industries.adventure_travel.prompt — is a real, importable Phase 2 module) and patches the given attributes via monkeypatch.setattr, which records the original value and restores it on teardown. A previous version of this helper created a *blank fake* module when `dotted_name` wasn't already in sys.modules, rather than importing the real one — harmless back when these were Phase 1 stand-ins for modules that didn't exist yet, but broken now: it left monkeypatch.setattr patching an empty module lacking the very attribute being set, raising AttributeError. Since Phase 2 these modules always exist, always import for real. """ target = importlib.import_module(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']) # change_detected is intentionally NOT reset here — detection and # extraction happen in the same scrape now, so it stays visible # (status badges, next cron run) until a later scrape confirms no # further change. See test_run_competitor_analysis_refresh_path_ # clears_change_detected_when_confirmed_stable below. assert updated_competitor['change_detected'] is True assert updated_competitor['last_scraped'] is not None # No prior product existed for this competitor+trip_name — this is # a brand-new-product discovery: is_new flag set, alert created. assert result['product']['is_new'] is True assert result['product']['first_seen'] is not None alerts = alert_repository.list_recent(tenant['id']) assert len(alerts) == 1 assert alerts[0]['alert_type'] == 'new_product' assert alerts[0]['product_id'] == result['product']['id'] def test_save_scrape_result_updates_existing_product_without_duplicating(monkeypatch): tenant, competitor = _make_tenant_and_competitor() existing = product_repository.create({ 'tenant_id': tenant['id'], 'competitor_id': competitor['id'], 'trip_name': 'Peru Classic', 'majority_price_usd': 2190, 'is_new': True, }) extracted = {'tripName': 'Peru Classic', 'majorityPrice': 2190, 'duration': 15} product = analysis_service._save_scrape_result( tenant['id'], competitor, extracted, {'url': competitor['website']} ) assert product['id'] == existing['id'] # updated in place, not duplicated assert product['is_new'] is False assert price_history_repository.list_for_product(product['id']) == [] assert alert_repository.list_recent(tenant['id']) == [] def test_save_scrape_result_creates_price_history_and_alert_above_threshold(monkeypatch): tenant, competitor = _make_tenant_and_competitor() existing = product_repository.create({ 'tenant_id': tenant['id'], 'competitor_id': competitor['id'], 'trip_name': 'Peru Classic', 'majority_price_usd': 2490, 'is_new': False, }) extracted = {'tripName': 'Peru Classic', 'majorityPrice': 2190, 'duration': 15} # -12% product = analysis_service._save_scrape_result( tenant['id'], competitor, extracted, {'url': competitor['website']} ) assert product['id'] == existing['id'] assert product['majority_price_usd'] == 2190 history = price_history_repository.list_for_product(product['id']) assert len(history) == 1 assert history[0]['change_amount'] == -300 assert history[0]['change_percent'] == -12.0 alerts = alert_repository.list_recent(tenant['id']) assert len(alerts) == 1 assert alerts[0]['alert_type'] == 'price_change' assert 'dropped' in alerts[0]['message'] assert alerts[0]['change_percent'] == -12.0 def test_save_scrape_result_price_change_below_threshold_no_alert(monkeypatch): tenant, competitor = _make_tenant_and_competitor() product_repository.create({ 'tenant_id': tenant['id'], 'competitor_id': competitor['id'], 'trip_name': 'Peru Classic', 'majority_price_usd': 2200, 'is_new': False, }) extracted = {'tripName': 'Peru Classic', 'majorityPrice': 2190, 'duration': 15} # ~-0.45% product = analysis_service._save_scrape_result( tenant['id'], competitor, extracted, {'url': competitor['website']} ) # Price history is still recorded (it genuinely changed)... assert len(price_history_repository.list_for_product(product['id'])) == 1 # ...but a sub-threshold move doesn't warrant an alert. assert alert_repository.list_recent(tenant['id']) == [] def test_save_scrape_result_unchanged_price_no_history_no_alert(monkeypatch): tenant, competitor = _make_tenant_and_competitor() product_repository.create({ 'tenant_id': tenant['id'], 'competitor_id': competitor['id'], 'trip_name': 'Peru Classic', 'majority_price_usd': 2190, 'is_new': False, }) extracted = {'tripName': 'Peru Classic', 'majorityPrice': 2190, 'duration': 15, 'comments': 'refreshed'} product = analysis_service._save_scrape_result( tenant['id'], competitor, extracted, {'url': competitor['website']} ) assert product['comments'] == 'refreshed' # still updated with fresh fields assert price_history_repository.list_for_product(product['id']) == [] assert alert_repository.list_recent(tenant['id']) == [] def test_save_scrape_result_price_raised_alert_wording(monkeypatch): tenant, competitor = _make_tenant_and_competitor() product_repository.create({ 'tenant_id': tenant['id'], 'competitor_id': competitor['id'], 'trip_name': 'Machu Picchu Trek', 'majority_price_usd': 1850, 'is_new': False, }) extracted = {'tripName': 'Machu Picchu Trek', 'majorityPrice': 2050, 'duration': 8} # +10.8% analysis_service._save_scrape_result(tenant['id'], competitor, extracted, {'url': competitor['website']}) alerts = alert_repository.list_recent(tenant['id']) assert len(alerts) == 1 assert 'raised' in alerts[0]['message'] assert alerts[0]['change_percent'] > 0 def test_detect_change_true_on_first_scrape(): _, competitor = _make_tenant_and_competitor() assert analysis_service.detect_change(competitor['id'], 'somehash') is True # First scrape never had a prior hash to compare against, so it # shouldn't flag change_detected — there's nothing to diff against. assert competitor_repository.get_by_id(competitor['id'])['change_detected'] is False def test_detect_change_true_and_flags_competitor_on_diff(): _, competitor = _make_tenant_and_competitor() scrape_repository.create({ 'tenant_id': 't1', 'competitor_id': competitor['id'], 'triggered_by': 'cron', 'status': 'complete', 'content_hash': 'old-hash', 'competitors_total': 1, 'competitors_done': 1, }) changed = analysis_service.detect_change(competitor['id'], 'new-hash') assert changed is True updated = competitor_repository.get_by_id(competitor['id']) assert updated['change_detected'] is True assert updated['change_detected_at'] is not None def test_detect_change_false_when_hash_unchanged(): _, competitor = _make_tenant_and_competitor() scrape_repository.create({ 'tenant_id': 't1', 'competitor_id': competitor['id'], 'triggered_by': 'cron', 'status': 'complete', 'content_hash': 'same-hash', 'competitors_total': 1, 'competitors_done': 1, }) assert analysis_service.detect_change(competitor['id'], 'same-hash') is False def test_run_competitor_analysis_refresh_path_skips_extraction_when_hash_unchanged(monkeypatch): tenant, competitor = _make_tenant_and_competitor(change_detected=True) product_repository.create({ 'tenant_id': tenant['id'], 'competitor_id': competitor['id'], 'trip_name': 'Peru Classic', }) from core.scraper import compute_content_hash fake_page_data = {'success': True, 'url': competitor['website'], 'text': 'unchanged content'} _install_fake_module(monkeypatch, 'core.scraper', scrape=lambda url, country=None: fake_page_data) prior_hash = compute_content_hash('unchanged content') scrape_repository.create({ 'tenant_id': tenant['id'], 'competitor_id': competitor['id'], 'triggered_by': 'cron', 'status': 'complete', 'content_hash': prior_hash, 'competitors_total': 1, 'competitors_done': 1, }) # extract_with_openrouter backs both the full-page extraction call # (refresh path, content=full scraped text) AND analyse_from_cache's # much cheaper narrative-only call (content=''). Skipping "LLM # extraction" means never invoking it with the full page content — # so the assertion checks what content it was called with, not # whether it was called at all. calls_with_content = [] _install_fake_module( monkeypatch, 'core.extractor', extract_with_openrouter=lambda prompt, content: calls_with_content.append(content) or {'narrative': 'cached'} ) _install_fake_module(monkeypatch, 'industries.adventure_travel.prompt', build_prompt=lambda *a, **k: '') result = analysis_service.run_competitor_analysis(tenant['id'], competitor, {}) assert result['path'] == 'refresh' assert result['unchanged'] is True assert calls_with_content == [''] # only the cheap narrative-only call happened, never full extraction updated = competitor_repository.get_by_id(competitor['id']) assert updated['last_scraped'] is not None # Confirmed no further change since the flag was set — this scrape # is what clears it (see the bug this fixes: a prior version reset # change_detected in the *changed* branch instead, making it # unobservable to anything outside a single scrape call). assert updated['change_detected'] is False def test_change_detected_stays_visible_until_next_scrape_confirms_stable(monkeypatch): """ Regression test for the bug where change_detected was set True by detect_change() and then immediately reset False before the same function call returned — making it unobservable to the CompetitorsPage status badge, the Apps Script sidebar, or the n8n daily cron's `change_detected = true` filter clause. Exercises two consecutive scrapes: the first finds a diff (flag should stay True after it returns), the second finds no further diff (flag should only clear then). """ tenant, competitor = _make_tenant_and_competitor() product_repository.create({ 'tenant_id': tenant['id'], 'competitor_id': competitor['id'], 'trip_name': 'Peru Classic', }) _install_fake_module(monkeypatch, 'industries.adventure_travel.prompt', build_prompt=lambda *a, **k: '') _install_fake_module( monkeypatch, 'core.extractor', extract_with_openrouter=lambda prompt, content: ( {'tripName': 'Peru Classic', 'majorityPrice': 2190} if content else {'narrative': 'cached'} ) ) # First scrape — no prior hash exists, so detect_change() treats it # as an initial baseline. changed=True takes the extraction branch. _install_fake_module( monkeypatch, 'core.scraper', scrape=lambda url, country=None: {'success': True, 'url': url, 'text': 'version A'} ) analysis_service.run_competitor_analysis(tenant['id'], competitor, {}) competitor = competitor_repository.get_by_id(competitor['id']) assert competitor['change_detected'] is False # first-ever scrape has nothing to diff against # Second scrape — content genuinely changed vs "version A". competitor['last_scraped'] = None # force needs_refresh() to take the refresh path again _install_fake_module( monkeypatch, 'core.scraper', scrape=lambda url, country=None: {'success': True, 'url': url, 'text': 'version B'} ) analysis_service.run_competitor_analysis(tenant['id'], competitor, {}) competitor = competitor_repository.get_by_id(competitor['id']) assert competitor['change_detected'] is True # visible — not reset by the same call that set it # Third scrape — identical to "version B", nothing further changed. competitor['last_scraped'] = None analysis_service.run_competitor_analysis(tenant['id'], competitor, {}) competitor = competitor_repository.get_by_id(competitor['id']) assert competitor['change_detected'] is False # now cleared, confirmed stable 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, {})