app.bettersight.io/backend/tests/test_api.py

256 lines
9.8 KiB
Python

"""
Integration tests for api.py routes, run against mock repositories
(USE_MOCK_REPOSITORIES=true, set in conftest.py before any import).
"""
from repositories.tenant_repository import tenant_repository
from repositories.seat_repository import seat_repository
from repositories.competitor_repository import competitor_repository
def _make_tenant(**overrides):
data = {
'name': 'G Adventures', 'email': 'pm@gadventures.com', 'domain': 'gadventures.com',
'tier': 'analyse', 'status': 'active', 'max_seats': 5,
}
data.update(overrides)
return tenant_repository.create(data)
def test_health_requires_no_auth(client):
response = client.get('/health')
assert response.status_code == 200
assert response.json == {'status': 'ok', 'version': '0.1.0-phase1'}
def test_protected_route_rejects_missing_api_key(client):
response = client.post('/validate-email', json={'email': 'pm@gadventures.com'})
assert response.status_code == 401
assert response.json['error'] is True
assert response.json['code'] == 'unauthorized'
def test_protected_route_rejects_wrong_api_key(client):
response = client.post(
'/validate-email', json={'email': 'pm@gadventures.com'},
headers={'X-API-Key': 'wrong-key'}
)
assert response.status_code == 401
def test_validate_email_happy_path(client, api_headers):
_make_tenant()
response = client.post('/validate-email', json={'email': 'pm@gadventures.com'}, headers=api_headers)
assert response.status_code == 200
assert response.json == {'valid': True, 'tier': 'analyse'}
def test_validate_email_missing_field_returns_structured_error(client, api_headers):
response = client.post('/validate-email', json={}, headers=api_headers)
assert response.status_code == 400
assert response.json['error'] is True
assert response.json['code'] == 'missing_field'
def test_add_seat_rejects_when_tenant_at_seat_limit(client, api_headers):
tenant = _make_tenant(max_seats=1)
seat_repository.create({'tenant_id': tenant['id'], 'email': 'first@gadventures.com', 'active': True})
response = client.post(
'/account/seats', json={'tenant_id': tenant['id'], 'email': 'second@gadventures.com'},
headers=api_headers
)
assert response.status_code == 400
assert response.json['code'] == 'seat_limit_reached'
def test_add_seat_succeeds_under_limit(client, api_headers):
tenant = _make_tenant(max_seats=5)
response = client.post(
'/account/seats', json={'tenant_id': tenant['id'], 'email': 'newuser@gadventures.com'},
headers=api_headers
)
assert response.status_code == 201
assert seat_repository.count_active(tenant['id']) == 1
def test_remove_seat(client, api_headers):
tenant = _make_tenant()
seat = seat_repository.create({'tenant_id': tenant['id'], 'email': 'x@gadventures.com', 'active': True})
response = client.delete(f'/account/seats/{seat["id"]}', headers=api_headers)
assert response.status_code == 200
assert seat_repository.count_active(tenant['id']) == 0
def test_get_competitors_resolves_tenant_by_email_header(client, api_headers):
tenant = _make_tenant()
competitor_repository.create({
'tenant_id': tenant['id'], 'name': 'Intrepid Travel', 'website': 'https://intrepidtravel.com',
'catalogue_url': 'https://intrepidtravel.com/adventures', 'primary_market': 'GB', 'active': True,
})
response = client.get('/competitors', headers={**api_headers, 'X-User-Email': 'pm@gadventures.com'})
assert response.status_code == 200
body = response.json
assert len(body) == 1
assert body[0]['name'] == 'Intrepid Travel'
assert body[0]['status'] == 'stale' # never scraped → stale
def test_get_competitors_unknown_email_returns_404(client, api_headers):
response = client.get('/competitors', headers={**api_headers, 'X-User-Email': 'nobody@nowhere.com'})
assert response.status_code == 404
def test_research_find_urls_requires_fields(client, api_headers):
response = client.post('/research/find-urls', json={}, headers=api_headers)
assert response.status_code == 400
assert response.json['code'] == 'missing_field'
def test_research_find_urls_omits_own_trip_without_tenant_id(client, api_headers, monkeypatch):
monkeypatch.setattr(
'api.trip_finder_service.find_all_competitor_urls',
lambda competitors, destination, duration, travel_style: [],
)
response = client.post('/research/find-urls', json={
'destination': 'Peru', 'duration': 15, 'travel_style': 'Classic', 'competitor_ids': [],
}, headers=api_headers)
assert response.status_code == 200
assert response.json['own_trip'] is None
def test_research_find_urls_omits_own_trip_when_tenant_has_no_website(client, api_headers, monkeypatch):
tenant = _make_tenant()
monkeypatch.setattr(
'api.trip_finder_service.find_all_competitor_urls',
lambda competitors, destination, duration, travel_style: [],
)
response = client.post('/research/find-urls', json={
'tenant_id': tenant['id'], 'destination': 'Peru', 'duration': 15,
'travel_style': 'Classic', 'competitor_ids': [],
}, headers=api_headers)
assert response.status_code == 200
assert response.json['own_trip'] is None
def test_research_find_urls_includes_own_trip_when_tenant_has_website(client, api_headers, monkeypatch):
tenant = _make_tenant(website='https://wildhorizons.com')
monkeypatch.setattr(
'api.trip_finder_service.find_all_competitor_urls',
lambda competitors, destination, duration, travel_style: [],
)
monkeypatch.setattr(
'api.trip_finder_service.find_own_trip_url',
lambda website, destination, duration, travel_style: {'url': f'{website}/peru-classic', 'confidence': 0.9, 'found': True},
)
response = client.post('/research/find-urls', json={
'tenant_id': tenant['id'], 'destination': 'Peru', 'duration': 15,
'travel_style': 'Classic', 'competitor_ids': [],
}, headers=api_headers)
assert response.status_code == 200
assert response.json['own_trip'] == {'url': 'https://wildhorizons.com/peru-classic', 'confidence': 0.9, 'found': True}
def test_research_enqueues_job_without_touching_real_redis(client, api_headers, monkeypatch):
import jobs
tenant = _make_tenant()
captured = {}
def _fake_enqueue(job_id, data):
captured['job_id'] = job_id
captured['data'] = data
return job_id
monkeypatch.setattr(jobs, 'enqueue_research_job', _fake_enqueue)
response = client.post('/research', json={
'tenant_id': tenant['id'], 'destination': 'Peru', 'duration': 15,
'travel_style': 'Classic', 'tab_type': 'Standard', 'competitors': [],
}, headers=api_headers)
assert response.status_code == 200
assert response.json['status'] == 'queued'
assert captured['job_id'] == response.json['job_id']
def test_research_persists_created_by_email(client, api_headers, monkeypatch):
import jobs
from repositories.scrape_repository import scrape_repository
tenant = _make_tenant()
monkeypatch.setattr(jobs, 'enqueue_research_job', lambda job_id, data: job_id)
response = client.post('/research', json={
'tenant_id': tenant['id'], 'destination': 'Peru', 'duration': 15,
'travel_style': 'Classic', 'tab_type': 'Standard', 'competitors': [],
'email': 'pm@gadventures.com',
}, headers=api_headers)
run = scrape_repository.get_by_id(response.json['job_id'])
assert run['created_by_email'] == 'pm@gadventures.com'
def test_research_persists_client_trip_from_trip_intent(client, api_headers, monkeypatch):
from repositories.client_trips_repository import client_trips_repository
import jobs
tenant = _make_tenant()
monkeypatch.setattr(jobs, 'enqueue_research_job', lambda job_id, data: job_id)
client.post('/research', json={
'tenant_id': tenant['id'], 'destination': 'Peru', 'duration': 15,
'travel_style': 'Classic', 'tab_type': 'Standard', 'competitors': [],
'productName': 'Inca Trail Classic',
}, headers=api_headers)
trips = client_trips_repository.list_for_tenant(tenant['id'])
assert len(trips) == 1
assert trips[0]['trip_name'] == 'Inca Trail Classic'
assert trips[0]['destination'] == 'Peru'
def test_research_still_queues_job_when_client_trip_save_fails(client, api_headers, monkeypatch):
import jobs
tenant = _make_tenant()
monkeypatch.setattr(jobs, 'enqueue_research_job', lambda job_id, data: job_id)
monkeypatch.setattr(
'services.embedding_service.save_client_trip',
lambda *a, **k: (_ for _ in ()).throw(RuntimeError('pocketbase down'))
)
response = client.post('/research', json={
'tenant_id': tenant['id'], 'destination': 'Peru', 'duration': 15,
'travel_style': 'Classic', 'tab_type': 'Standard', 'competitors': [],
}, headers=api_headers)
assert response.status_code == 200 # side-effect failure never blocks the primary operation
assert response.json['status'] == 'queued'
def test_bulk_import_rejects_more_than_fifty_rows(client, api_headers):
tenant = _make_tenant()
rows = [
{'name': f'Comp {i}', 'website': f'https://comp{i}.com',
'catalogue_url': f'https://comp{i}.com/trips', 'primary_market': 'GB'}
for i in range(51)
]
response = client.post(
'/account/competitors/bulk', json={'tenant_id': tenant['id'], 'rows': rows}, headers=api_headers
)
assert response.status_code == 400
assert response.json['code'] == 'too_many_rows'
def test_competitors_template_download(client, api_headers):
response = client.get('/account/competitors/template', headers=api_headers)
assert response.status_code == 200
assert response.mimetype == 'text/csv'
assert b'name,website,catalogue_url,primary_market' in response.data