393 lines
18 KiB
Python
393 lines
18 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 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, {})
|