import sys import types from services import embedding_service from repositories.client_trips_repository import client_trips_repository def test_cosine_similarity_identical_vectors_is_one(): assert embedding_service._cosine_similarity([1, 0, 0], [1, 0, 0]) == 1.0 def test_cosine_similarity_orthogonal_vectors_is_zero(): assert embedding_service._cosine_similarity([1, 0], [0, 1]) == 0.0 def test_cosine_similarity_zero_vector_returns_zero(): assert embedding_service._cosine_similarity([0, 0], [1, 1]) == 0.0 def test_find_comparable_trips_filters_by_threshold_and_sorts(): client_trips = [ {'trip_name': 'Inca Trail Classic', 'price_usd': 1999, 'duration_days': 15, 'embedding': [1, 0, 0]}, ] competitor_trips = [ {'id': 'p1', 'majority_price_usd': 2190, 'duration_days': 15, 'embedding': [1, 0, 0]}, # perfect match {'id': 'p2', 'majority_price_usd': 1800, 'duration_days': 10, 'embedding': [0, 1, 0]}, # orthogonal, filtered out ] matches = embedding_service.find_comparable_trips(client_trips, competitor_trips, threshold=0.75) assert len(matches) == 1 assert matches[0]['competitor_product'] == 'p1' assert matches[0]['price_difference'] == 2190 - 1999 assert matches[0]['duration_difference'] == 0 def test_find_comparable_trips_skips_records_without_embeddings(): client_trips = [{'trip_name': 'x', 'embedding': None}] competitor_trips = [{'id': 'p1', 'embedding': [1, 0]}] assert embedding_service.find_comparable_trips(client_trips, competitor_trips) == [] def test_safe_diff_returns_none_when_either_side_missing(): assert embedding_service._safe_diff(None, 5) is None assert embedding_service._safe_diff(5, None) is None assert embedding_service._safe_diff(10, 4) == 6 def test_embed_trip_calls_openrouter_embeddings_api(monkeypatch): captured = {} class _FakeEmbeddingResponse: class _Data: embedding = [0.1, 0.2, 0.3] data = [_Data()] class _FakeEmbeddings: def create(self, model, input): captured['model'] = model captured['input'] = input return _FakeEmbeddingResponse() class _FakeOpenAI: def __init__(self, api_key, base_url): self.embeddings = _FakeEmbeddings() fake_openai_module = types.ModuleType('openai') fake_openai_module.OpenAI = _FakeOpenAI monkeypatch.setitem(sys.modules, 'openai', fake_openai_module) vector = embedding_service.embed_trip({ 'trip_name': 'Inca Trail Classic', 'destination': 'Peru', 'duration_days': 15, 'activities': 'Trekking', 'start_location': 'Cusco', }) assert vector == [0.1, 0.2, 0.3] assert captured['model'] == embedding_service.EMBEDDING_MODEL assert 'Peru' in captured['input'] def test_save_client_trip_creates_new_record_with_product_name(): trip = embedding_service.save_client_trip('t1', 'Peru', 15, 'Classic', product_name='Inca Trail Classic') assert trip['tenant_id'] == 't1' assert trip['trip_name'] == 'Inca Trail Classic' assert trip['destination'] == 'Peru' assert trip['duration_days'] == 15 def test_save_client_trip_falls_back_to_destination_and_style_when_no_product_name(): trip = embedding_service.save_client_trip('t1', 'Peru', 15, 'Classic') assert trip['trip_name'] == 'Peru Classic' def test_save_client_trip_upserts_existing_by_tenant_and_trip_name(): first = embedding_service.save_client_trip('t1', 'Peru', 15, 'Classic', product_name='Inca Trail') second = embedding_service.save_client_trip('t1', 'Peru', 16, 'Classic', product_name='Inca Trail') assert second['id'] == first['id'] # updated in place, not duplicated assert second['duration_days'] == 16 assert len(client_trips_repository.list_for_tenant('t1')) == 1 def test_save_client_trip_clears_stale_embedding_when_destination_or_duration_changes(): client_trips_repository.create({ 'id': 'ct1', 'tenant_id': 't1', 'trip_name': 'Inca Trail', 'destination': 'Peru', 'duration_days': 15, 'embedding': [0.1, 0.2, 0.3], }) updated = embedding_service.save_client_trip('t1', 'Peru', 16, 'Classic', product_name='Inca Trail') assert updated['embedding'] is None # stale — duration changed, must be re-embedded def test_save_client_trip_keeps_embedding_when_nothing_relevant_changed(): client_trips_repository.create({ 'id': 'ct1', 'tenant_id': 't1', 'trip_name': 'Inca Trail', 'destination': 'Peru', 'duration_days': 15, 'embedding': [0.1, 0.2, 0.3], }) updated = embedding_service.save_client_trip('t1', 'Peru', 15, 'Classic', product_name='Inca Trail') assert updated['embedding'] == [0.1, 0.2, 0.3] # unchanged inputs — no need to invalidate def test_save_client_trip_persists_url_extracted_price_and_activities_when_given(): trip = embedding_service.save_client_trip( 't1', 'Peru', 15, 'Classic', product_name='Inca Trail', url='https://client.com/inca-trail', extracted={'tripName': 'Inca Trail'}, price_usd=1999, activities='Trekking, Camping', ) assert trip['url'] == 'https://client.com/inca-trail' assert trip['extracted'] == {'tripName': 'Inca Trail'} assert trip['price_usd'] == 1999 assert trip['activities'] == 'Trekking, Camping' def test_save_client_trip_omits_own_trip_fields_when_not_given(): # The original typed-values-only call site (api.py's /research route) # must be unaffected — no url/extracted/price_usd/activities keys at all. trip = embedding_service.save_client_trip('t1', 'Peru', 15, 'Classic', product_name='Inca Trail') assert 'url' not in trip assert 'extracted' not in trip assert 'price_usd' not in trip assert 'activities' not in trip def _fake_scrape(success=True, text='Peru Classic 15 days...'): def _scrape(url): return {'success': success, 'text': text, 'tables': '', 'url': url} return _scrape def test_scrape_own_trip_scrapes_and_extracts_when_no_cached_row(monkeypatch): monkeypatch.setattr('core.scraper.scrape', _fake_scrape(), raising=False) monkeypatch.setattr( 'core.extractor.extract_with_openrouter', lambda prompt, content='': {'tripName': 'Inca Trail Classic', 'majorityPrice': 1999, 'activities': 'Trekking'}, raising=False, ) context = { 'destination': 'Peru', 'duration': 15, 'travel_style': 'Classic', 'product_name': 'Inca Trail Classic', 'company_name': 'Wild Horizons', 'tab_type': 'Standard', } result = embedding_service.scrape_own_trip('t1', 'https://client.com/inca-trail', context) assert result == { 'tripName': 'Inca Trail Classic', 'majorityPrice': 1999, 'activities': 'Trekking', 'link': 'https://client.com/inca-trail', 'cached': False, } saved = client_trips_repository.get_by_tenant_and_name('t1', 'Inca Trail Classic') assert saved['url'] == 'https://client.com/inca-trail' assert saved['price_usd'] == 1999 assert saved['last_scraped'] is not None def test_scrape_own_trip_reuses_fresh_cached_extraction_without_rescraping(monkeypatch): from datetime import datetime, timezone client_trips_repository.create({ 'id': 'ct1', 'tenant_id': 't1', 'trip_name': 'Inca Trail Classic', 'destination': 'Peru', 'duration_days': 15, 'extracted': {'tripName': 'Inca Trail Classic', 'majorityPrice': 1999}, 'last_scraped': datetime.now(timezone.utc).isoformat(), }) calls = [] monkeypatch.setattr('core.scraper.scrape', lambda url: calls.append(url) or _fake_scrape()(url), raising=False) context = {'destination': 'Peru', 'duration': 15, 'travel_style': 'Classic', 'product_name': 'Inca Trail Classic'} result = embedding_service.scrape_own_trip('t1', 'https://client.com/inca-trail', context) assert result == {'tripName': 'Inca Trail Classic', 'majorityPrice': 1999, 'cached': True} assert calls == [] # cache was fresh — scrape() never called def test_scrape_own_trip_rescrapes_when_force_refresh_even_if_fresh(monkeypatch): from datetime import datetime, timezone client_trips_repository.create({ 'id': 'ct1', 'tenant_id': 't1', 'trip_name': 'Inca Trail Classic', 'destination': 'Peru', 'duration_days': 15, 'extracted': {'tripName': 'stale'}, 'last_scraped': datetime.now(timezone.utc).isoformat(), }) monkeypatch.setattr('core.scraper.scrape', _fake_scrape(), raising=False) monkeypatch.setattr( 'core.extractor.extract_with_openrouter', lambda prompt, content='': {'tripName': 'fresh'}, raising=False, ) context = { 'destination': 'Peru', 'duration': 15, 'travel_style': 'Classic', 'product_name': 'Inca Trail Classic', 'force_refresh': True, } result = embedding_service.scrape_own_trip('t1', 'https://client.com/inca-trail', context) assert result == {'tripName': 'fresh', 'link': 'https://client.com/inca-trail', 'cached': False} def test_scrape_own_trip_returns_none_on_scrape_failure_without_raising(monkeypatch): monkeypatch.setattr('core.scraper.scrape', _fake_scrape(success=False, text='Blocked'), raising=False) context = {'destination': 'Peru', 'duration': 15, 'travel_style': 'Classic', 'product_name': 'Inca Trail Classic'} result = embedding_service.scrape_own_trip('t1', 'https://client.com/inca-trail', context) assert result is None def test_scrape_own_trip_returns_none_and_never_raises_on_unexpected_error(monkeypatch): def _raise(url): raise RuntimeError('boom') monkeypatch.setattr('core.scraper.scrape', _raise, raising=False) context = {'destination': 'Peru', 'duration': 15, 'travel_style': 'Classic', 'product_name': 'Inca Trail Classic'} result = embedding_service.scrape_own_trip('t1', 'https://client.com/inca-trail', context) assert result is None