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

1224 lines
53 KiB
Python

"""
Additional route coverage beyond tests/test_api.py's core happy-path
set — account management, billing, internal (n8n) routes, and the
Stripe webhook signature-verification path.
"""
import importlib
import types
import pytest
from repositories.tenant_repository import tenant_repository
from repositories.seat_repository import seat_repository
from repositories.competitor_repository import competitor_repository
from repositories.scrape_repository import scrape_repository
from repositories.battlecard_repository import battlecard_repository
from repositories.comparable_repository import comparable_repository
from repositories.alert_repository import alert_repository
def _make_tenant(**overrides):
data = {
'name': 'G Adventures', 'email': 'pm@gadventures.com', 'domain': 'gadventures.com',
'tier': 'analyse', 'status': 'active', 'max_seats': 5,
'onboarding_checklist': {'add_competitor': False, 'tour_step': 0},
}
data.update(overrides)
return tenant_repository.create(data)
def _install_fake_module(monkeypatch, dotted_name, **attrs):
"""
Imports the real module at `dotted_name` (core.extractor,
industries.adventure_travel.prompt — always real, importable Phase
2 modules) and patches attributes via monkeypatch.setattr, which
records the original value and restores it on teardown. Must
import the real module rather than substitute a blank fake one —
see the identical helper in tests/services/test_analysis_service.py
for why.
"""
target = importlib.import_module(dotted_name)
for key, value in attrs.items():
monkeypatch.setattr(target, key, value)
# ── Account ────────────────────────────────────────────────────────
def test_get_account_returns_tenant_with_seats(client, api_headers, monkeypatch):
tenant = _make_tenant()
seat_repository.create({'tenant_id': tenant['id'], 'email': 'pm@gadventures.com', 'active': True})
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: tenant['id'])
response = client.get(f'/account/{tenant["id"]}', headers=api_headers)
assert response.status_code == 200
assert response.json['name'] == 'G Adventures'
assert len(response.json['seats']) == 1
def test_get_account_unknown_tenant_404(client, api_headers, monkeypatch):
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: 'does-not-exist')
response = client.get('/account/does-not-exist', headers=api_headers)
assert response.status_code == 404
def test_get_account_rejects_mismatched_session_tenant(client, api_headers, monkeypatch):
tenant = _make_tenant()
other_tenant = _make_tenant(domain='otherco.com', email='pm@otherco.com')
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: other_tenant['id'])
response = client.get(f'/account/{tenant["id"]}', headers=api_headers)
assert response.status_code == 403
def test_get_my_account_requires_session(client, api_headers):
response = client.get('/account/me', headers=api_headers)
assert response.status_code == 401
def test_get_my_account_returns_own_tenant(client, api_headers, monkeypatch):
tenant = _make_tenant()
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: tenant['id'])
response = client.get('/account/me', headers=api_headers)
assert response.status_code == 200
assert response.json['name'] == 'G Adventures'
def test_update_timezone_requires_session(client, api_headers):
response = client.patch('/account/timezone', json={'timezone': 'Europe/London'}, headers=api_headers)
assert response.status_code == 401
def test_update_timezone_requires_field(client, api_headers, monkeypatch):
tenant = _make_tenant()
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: tenant['id'])
response = client.patch('/account/timezone', json={}, headers=api_headers)
assert response.status_code == 400
assert response.json['code'] == 'missing_field'
def test_update_timezone_sets_when_empty(client, api_headers, monkeypatch):
tenant = _make_tenant()
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: tenant['id'])
response = client.patch('/account/timezone', json={'timezone': 'Europe/London'}, headers=api_headers)
assert response.status_code == 200
assert response.json['timezone'] == 'Europe/London'
assert tenant_repository.get_by_id(tenant['id'])['timezone'] == 'Europe/London'
def test_update_timezone_never_overwrites_existing(client, api_headers, monkeypatch):
tenant = tenant_repository.create({
'name': 'G Adventures', 'domain': 'gadventures.com', 'tier': 'analyse',
'status': 'active', 'max_seats': 5, 'timezone': 'America/Toronto',
})
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: tenant['id'])
response = client.patch('/account/timezone', json={'timezone': 'Europe/London'}, headers=api_headers)
assert response.status_code == 200
assert response.json['timezone'] == 'America/Toronto' # unchanged — first-write-wins
def test_update_website_requires_session(client, api_headers):
response = client.patch('/account/website', json={'website': 'https://client.com'}, headers=api_headers)
assert response.status_code == 401
def test_update_website_requires_field(client, api_headers, monkeypatch):
tenant = _make_tenant()
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: tenant['id'])
response = client.patch('/account/website', json={}, headers=api_headers)
assert response.status_code == 400
assert response.json['code'] == 'missing_field'
def test_update_website_is_freely_editable_unlike_timezone(client, api_headers, monkeypatch):
tenant = tenant_repository.create({
'name': 'Wild Horizons', 'domain': 'wildhorizons.com', 'tier': 'analyse',
'status': 'active', 'max_seats': 5, 'website': 'https://old.wildhorizons.com',
})
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: tenant['id'])
response = client.patch('/account/website', json={'website': 'https://wildhorizons.com'}, headers=api_headers)
assert response.status_code == 200
assert response.json['website'] == 'https://wildhorizons.com'
assert tenant_repository.get_by_id(tenant['id'])['website'] == 'https://wildhorizons.com'
def test_update_competitor(client, api_headers):
tenant = _make_tenant()
competitor = competitor_repository.create({
'tenant_id': tenant['id'], 'name': 'Intrepid', 'website': 'https://intrepidtravel.com',
'catalogue_url': 'https://intrepidtravel.com/adventures', 'primary_market': 'GB', 'active': True,
})
response = client.put(
f'/account/competitors/{competitor["id"]}', json={'primary_market': 'US'}, headers=api_headers
)
assert response.status_code == 200
assert response.json['primary_market'] == 'US'
def test_update_unknown_competitor_404(client, api_headers):
response = client.put('/account/competitors/nope', json={}, headers=api_headers)
assert response.status_code == 404
def test_delete_competitor_deactivates(client, api_headers):
tenant = _make_tenant()
competitor = competitor_repository.create({
'tenant_id': tenant['id'], 'name': 'Intrepid', 'website': 'https://intrepidtravel.com',
'catalogue_url': 'https://intrepidtravel.com/adventures', 'primary_market': 'GB',
'active': True,
})
response = client.delete(f'/account/competitors/{competitor["id"]}', headers=api_headers)
assert response.status_code == 200
assert competitor_repository.get_by_id(competitor['id'])['active'] is False
def test_delete_unknown_competitor_404(client, api_headers):
response = client.delete('/account/competitors/nope', headers=api_headers)
assert response.status_code == 404
def test_account_template_returns_configured_url(client, api_headers, monkeypatch):
monkeypatch.setattr('api.TEMPLATE_SHEET_URL', 'https://docs.google.com/spreadsheets/d/abc123/copy')
response = client.get('/account/template', headers=api_headers)
assert response.status_code == 200
assert response.json['template_url'] == 'https://docs.google.com/spreadsheets/d/abc123/copy'
def test_onboarding_patch_requires_resolvable_session(client, api_headers):
response = client.patch('/account/onboarding', json={'tour_step': 2}, headers=api_headers)
assert response.status_code == 401
def test_onboarding_patch_updates_checklist(client, api_headers, monkeypatch):
tenant = _make_tenant()
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: tenant['id'])
response = client.patch(
'/account/onboarding', json={'tour_step': 2},
headers={**api_headers, 'Authorization': 'Bearer sometoken'}
)
assert response.status_code == 200
assert response.json['onboarding_checklist']['tour_step'] == 2
def test_onboarding_patch_falls_back_to_email_when_no_jwt_session(client, api_headers):
# This is the Apps Script's only path — it has no PocketBase JWT,
# just the PM's Google email via Session.getActiveUser().
tenant = _make_tenant()
response = client.patch(
'/account/onboarding', json={'email': 'pm@gadventures.com', 'sync_from_sheet': True},
headers=api_headers
)
assert response.status_code == 200
assert response.json['onboarding_checklist']['sync_from_sheet'] is True
def test_onboarding_patch_401_when_neither_jwt_nor_email_resolve(client, api_headers):
response = client.patch(
'/account/onboarding', json={'email': 'nobody@nowhere.com', 'sync_from_sheet': True},
headers=api_headers
)
assert response.status_code == 401
def test_delete_account_requires_tenant_id(client, api_headers):
response = client.post('/account/delete', json={}, headers=api_headers)
assert response.status_code == 400
def test_delete_account_unknown_tenant_404(client, api_headers):
response = client.post('/account/delete', json={'tenant_id': 'does-not-exist'}, headers=api_headers)
assert response.status_code == 404
def test_delete_account_erases_every_tenant_scoped_collection(client, api_headers, monkeypatch):
from repositories.product_repository import product_repository
from repositories.price_history_repository import price_history_repository
from repositories.alert_repository import alert_repository
from repositories.battlecard_repository import battlecard_repository
from repositories.comparable_repository import comparable_repository
from repositories.client_trips_repository import client_trips_repository
tenant = _make_tenant()
competitor = competitor_repository.create({
'tenant_id': tenant['id'], 'name': 'Intrepid', 'website': 'https://intrepidtravel.com',
'catalogue_url': 'https://intrepidtravel.com/adventures', 'primary_market': 'GB', 'active': True,
})
seat_repository.create({'tenant_id': tenant['id'], 'email': 'pm@gadventures.com', 'active': True})
product = product_repository.create({
'tenant_id': tenant['id'], 'competitor_id': competitor['id'], 'trip_name': 'Peru Classic',
})
price_history_repository.create({'tenant_id': tenant['id'], 'product_id': product['id'], 'majority_price_usd': 2190})
scrape_repository.create({
'tenant_id': tenant['id'], 'triggered_by': 'manual', 'status': 'complete',
'competitors_total': 1, 'competitors_done': 1,
})
alert = alert_repository.create({
'tenant_id': tenant['id'], 'competitor_id': competitor['id'], 'alert_type': 'new_product', 'message': 'x',
})
battlecard_repository.upsert(competitor['id'], {'tenant_id': tenant['id'], 'content': 'x'})
comparable_repository.create({
'tenant_id': tenant['id'], 'client_product': 'Inca Trail', 'competitor_product': product['id'],
'similarity_score': 0.9,
})
client_trips_repository.create({'tenant_id': tenant['id'], 'trip_name': 'Inca Trail'})
sent = []
monkeypatch.setattr('services.email_service.send_account_deleted_email', lambda email, name: sent.append(email))
response = client.post('/account/delete', json={'tenant_id': tenant['id']}, headers=api_headers)
assert response.status_code == 200
assert tenant_repository.get_by_id(tenant['id']) is None # hard-deleted, not just deactivated
assert competitor_repository.get_by_id(competitor['id']) is None
assert seat_repository.list_for_tenant(tenant['id']) == []
assert product_repository.list_all_for_tenant(tenant['id']) == []
assert price_history_repository.list_for_tenant(tenant['id']) == []
assert scrape_repository.list_all_for_tenant(tenant['id']) == []
assert alert_repository.get_by_id(alert['id']) is None
assert battlecard_repository.list_for_tenant(tenant['id']) == []
assert comparable_repository.list_for_tenant(tenant['id'], include_dismissed=True) == []
assert client_trips_repository.list_for_tenant(tenant['id']) == []
assert sent == ['pm@gadventures.com']
def test_delete_account_side_effect_failures_dont_block_erasure(client, api_headers, monkeypatch):
"""Stripe cancellation and the confirmation email are side effects — a failure in either never blocks erasure."""
tenant = _make_tenant(stripe_subscription_id='sub_123')
monkeypatch.setattr(
'services.billing_service._stripe',
lambda: type('_S', (), {'Subscription': type('_Sub', (), {
'delete': staticmethod(lambda *a, **k: (_ for _ in ()).throw(RuntimeError('stripe down')))
})})()
)
monkeypatch.setattr(
'services.email_service.send_account_deleted_email',
lambda *a, **k: (_ for _ in ()).throw(RuntimeError('resend down'))
)
response = client.post('/account/delete', json={'tenant_id': tenant['id']}, headers=api_headers)
assert response.status_code == 200
assert tenant_repository.get_by_id(tenant['id']) is None
# ── Billing ────────────────────────────────────────────────────────
def test_billing_portal_requires_session(client, api_headers):
response = client.get('/billing/portal', headers=api_headers)
assert response.status_code == 401
def test_billing_portal_no_stripe_customer_returns_400(client, api_headers, monkeypatch):
tenant = _make_tenant()
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: tenant['id'])
response = client.get(
'/billing/portal', headers={**api_headers, 'Authorization': 'Bearer x'}
)
assert response.status_code == 400
assert response.json['code'] == 'no_stripe_customer'
def test_billing_upgrade_requires_fields(client, api_headers):
response = client.post('/billing/upgrade', json={}, headers=api_headers)
assert response.status_code == 400
def test_billing_upgrade_unconfigured_tier_returns_400(client, api_headers):
tenant = _make_tenant()
response = client.post(
'/billing/upgrade', json={'tenant_id': tenant['id'], 'target_tier': 'discover'}, headers=api_headers
)
assert response.status_code == 400
assert response.json['code'] == 'upgrade_failed'
# ── Internal (n8n) ─────────────────────────────────────────────────
def test_internal_battlecard_requires_tenant_id(client, api_headers):
response = client.post('/internal/battlecard/c1', json={}, headers=api_headers)
assert response.status_code == 400
def test_internal_battlecard_generates_card(client, api_headers, monkeypatch):
tenant = _make_tenant()
competitor = competitor_repository.create({
'tenant_id': tenant['id'], 'name': 'Intrepid', 'website': 'https://intrepidtravel.com',
'catalogue_url': 'https://intrepidtravel.com/adventures', 'primary_market': 'GB', 'active': True,
})
_install_fake_module(
monkeypatch, 'core.extractor',
extract_with_openrouter=lambda prompt, content: {
'positioning': 'x', 'strengths': [], 'weaknesses': [], 'pricing_observation': 'y'
}
)
response = client.post(
f'/internal/battlecard/{competitor["id"]}', json={'tenant_id': tenant['id']}, headers=api_headers
)
assert response.status_code == 200
assert response.json['content_json']['positioning'] == 'x'
def test_internal_weekly_brief(client, api_headers, monkeypatch):
called = []
monkeypatch.setattr('api.brief_service.send_weekly_brief', lambda tenant_id: called.append(tenant_id))
response = client.post('/internal/weekly-brief/t1', headers=api_headers)
assert response.status_code == 200
assert called == ['t1']
def test_internal_daily_digest(client, api_headers, monkeypatch):
monkeypatch.setattr('api.alert_service.send_daily_digest', lambda tenant_id: tenant_id == 't1')
response = client.post('/internal/daily-digest/t1', headers=api_headers)
assert response.status_code == 200
assert response.json['sent'] is True
def test_internal_data_cleanup(client, api_headers, monkeypatch):
monkeypatch.setattr(
'services.retention_service.run_weekly_cleanup',
lambda: {'scrape_runs_deleted': 3, 'alerts_deleted': 1, 'price_history_deleted': 0}
)
response = client.post('/internal/data-cleanup', headers=api_headers)
assert response.status_code == 200
assert response.json == {'scrape_runs_deleted': 3, 'alerts_deleted': 1, 'price_history_deleted': 0}
def test_internal_welcome_email_unknown_tenant_404(client, api_headers):
response = client.post('/internal/welcome-email/does-not-exist', headers=api_headers)
assert response.status_code == 404
def test_internal_welcome_email_sends(client, api_headers, monkeypatch):
tenant = _make_tenant()
monkeypatch.setattr('api.email_service.send_welcome_email', lambda email, name, url=None: True)
response = client.post(
f'/internal/welcome-email/{tenant["id"]}', json={'template_url': 'https://sheets/x'}, headers=api_headers
)
assert response.status_code == 200
assert response.json['sent'] is True
def test_internal_trial_reminder_no_active_trial_404(client, api_headers):
tenant = _make_tenant(trial_ends_at=None)
response = client.post(f'/internal/trial-reminder/{tenant["id"]}', headers=api_headers)
assert response.status_code == 404
def test_internal_trial_reminder_sends(client, api_headers, monkeypatch):
from datetime import datetime, timedelta, timezone
tenant = _make_tenant(trial_ends_at=(datetime.now(timezone.utc) + timedelta(days=2)).isoformat())
captured = {}
monkeypatch.setattr(
'api.email_service.send_trial_reminder_email',
lambda email, name, days_left, trial_end_date: captured.update(days_left=days_left) or True
)
response = client.post(f'/internal/trial-reminder/{tenant["id"]}', headers=api_headers)
assert response.status_code == 200
assert response.json['sent'] is True
assert captured['days_left'] in (1, 2) # datetime.days truncation can land on either
def test_internal_alert_email_unknown_alert_404(client, api_headers):
response = client.post('/internal/alert-email/does-not-exist', headers=api_headers)
assert response.status_code == 404
def test_internal_alert_email_unsupported_type_400(client, api_headers):
tenant = _make_tenant()
competitor = competitor_repository.create({
'tenant_id': tenant['id'], 'name': 'Intrepid', 'website': 'https://intrepidtravel.com',
'catalogue_url': 'https://intrepidtravel.com/adventures', 'primary_market': 'GB', 'active': True,
})
alert = alert_repository.create({
'tenant_id': tenant['id'], 'competitor_id': competitor['id'], 'alert_type': 'new_comparable', 'message': 'x',
})
response = client.post(f'/internal/alert-email/{alert["id"]}', headers=api_headers)
assert response.status_code == 400
assert response.json['code'] == 'unsupported_alert_type'
def test_internal_alert_email_sends_price_change_and_marks_delivered(client, api_headers, monkeypatch):
tenant = _make_tenant()
competitor = competitor_repository.create({
'tenant_id': tenant['id'], 'name': 'Intrepid', 'website': 'https://intrepidtravel.com',
'catalogue_url': 'https://intrepidtravel.com/adventures', 'primary_market': 'GB', 'active': True,
})
alert = alert_repository.create({
'tenant_id': tenant['id'], 'competitor_id': competitor['id'], 'alert_type': 'price_change',
'message': 'Price dropped', 'change_percent': -12,
})
monkeypatch.setattr('api.email_service.send_price_alert_email', lambda email, alert_data: True)
response = client.post(f'/internal/alert-email/{alert["id"]}', headers=api_headers)
assert response.status_code == 200
assert response.json['sent'] is True
assert alert_repository.get_by_id(alert['id'])['delivered_email'] is True
def test_internal_alert_email_sends_new_product(client, api_headers, monkeypatch):
tenant = _make_tenant()
competitor = competitor_repository.create({
'tenant_id': tenant['id'], 'name': 'Flash Pack', 'website': 'https://flashpack.com',
'catalogue_url': 'https://flashpack.com/adventures', 'primary_market': 'US', 'active': True,
})
alert = alert_repository.create({
'tenant_id': tenant['id'], 'competitor_id': competitor['id'], 'alert_type': 'new_product', 'message': 'New trip',
})
monkeypatch.setattr('api.email_service.send_new_product_email', lambda email, alert_data: True)
response = client.post(f'/internal/alert-email/{alert["id"]}', headers=api_headers)
assert response.status_code == 200
assert response.json['sent'] is True
def test_internal_scrape_enqueues_job(client, api_headers, monkeypatch):
import jobs
tenant = _make_tenant()
captured = {}
monkeypatch.setattr(jobs, 'enqueue_research_job', lambda job_id, data: captured.update(job_id=job_id))
response = client.post(f'/internal/scrape/{tenant["id"]}', json={'competitors': []}, headers=api_headers)
assert response.status_code == 200
assert response.json['job_id'] == captured['job_id']
def test_internal_embed_products(client, api_headers, monkeypatch):
from repositories.product_repository import product_repository
from repositories.client_trips_repository import client_trips_repository
tenant = _make_tenant()
competitor = competitor_repository.create({
'tenant_id': tenant['id'], 'name': 'Intrepid', 'website': 'https://intrepidtravel.com',
'catalogue_url': 'https://intrepidtravel.com/adventures', 'primary_market': 'GB', 'active': True,
})
product_repository.create({
'tenant_id': tenant['id'], 'competitor_id': competitor['id'], 'trip_name': 'Peru Classic',
})
client_trips_repository.create({'tenant_id': tenant['id'], 'trip_name': 'Inca Trail'})
monkeypatch.setattr('services.embedding_service.embed_trip', lambda trip: [0.1, 0.2])
response = client.post(f'/internal/embed-products/{tenant["id"]}', headers=api_headers)
assert response.status_code == 200
assert response.json['embedded'] == 2
assert response.json['embedded_products'] == 1
assert response.json['embedded_client_trips'] == 1
def test_internal_match_comparable(client, api_headers, monkeypatch):
from repositories.client_trips_repository import client_trips_repository
tenant = _make_tenant()
client_trips_repository.create({'tenant_id': tenant['id'], 'trip_name': 'Inca Trail', 'embedding': [1, 0]})
monkeypatch.setattr(
'services.embedding_service.find_comparable_trips',
lambda client_trips, competitor_trips, threshold=0.75: [
{'client_product': 'x', 'competitor_product': 'p1', 'similarity_score': 0.9,
'price_difference': 10, 'duration_difference': 0}
]
)
response = client.post(f'/internal/match-comparable/{tenant["id"]}', headers=api_headers)
assert response.status_code == 200
assert response.json['matches_created'] == 1
assert response.json['matches_updated'] == 0
matches = comparable_repository.list_for_tenant(tenant['id'])
assert len(matches) == 1
assert matches[0]['last_matched'] is not None
def test_internal_match_comparable_upserts_on_repeat_run(client, api_headers, monkeypatch):
tenant = _make_tenant()
monkeypatch.setattr(
'services.embedding_service.find_comparable_trips',
lambda client_trips, competitor_trips, threshold=0.75: [
{'client_product': 'x', 'competitor_product': 'p1', 'similarity_score': 0.9,
'price_difference': 10, 'duration_difference': 0}
]
)
client.post(f'/internal/match-comparable/{tenant["id"]}', headers=api_headers)
response = client.post(f'/internal/match-comparable/{tenant["id"]}', headers=api_headers)
assert response.json['matches_created'] == 0
assert response.json['matches_updated'] == 1
# Same pair matched twice — updated in place, never duplicated.
assert len(comparable_repository.list_for_tenant(tenant['id'])) == 1
def test_internal_match_comparable_preserves_dismissed_flag_on_upsert(client, api_headers, monkeypatch):
tenant = _make_tenant()
monkeypatch.setattr(
'services.embedding_service.find_comparable_trips',
lambda client_trips, competitor_trips, threshold=0.75: [
{'client_product': 'x', 'competitor_product': 'p1', 'similarity_score': 0.9,
'price_difference': 10, 'duration_difference': 0}
]
)
client.post(f'/internal/match-comparable/{tenant["id"]}', headers=api_headers)
match = comparable_repository.list_for_tenant(tenant['id'], include_dismissed=True)[0]
comparable_repository.dismiss(match['id'])
client.post(f'/internal/match-comparable/{tenant["id"]}', headers=api_headers)
still_dismissed = comparable_repository.list_for_tenant(tenant['id'], include_dismissed=True)[0]
assert still_dismissed['dismissed'] is True
# ── Research history — latest (Apps Script sidebar pull) ────────────
def test_research_history_latest_requires_known_email(client, api_headers):
response = client.get('/research/history/latest', query_string={'email': 'nobody@nowhere.com'}, headers=api_headers)
assert response.status_code == 404
def test_research_history_latest_no_completed_runs_yet(client, api_headers):
_make_tenant()
response = client.get(
'/research/history/latest', query_string={'email': 'pm@gadventures.com'}, headers=api_headers
)
assert response.status_code == 404
assert response.json['code'] == 'not_found'
def test_research_history_latest_returns_most_recent_completed_run(client, api_headers):
tenant = _make_tenant()
scrape_repository.create({
'tenant_id': tenant['id'], 'triggered_by': 'manual', 'status': 'failed',
'competitors_total': 1, 'competitors_done': 1, 'results': {},
'started_at': '2026-06-01T00:00:00Z',
})
latest = scrape_repository.create({
'tenant_id': tenant['id'], 'triggered_by': 'manual', 'status': 'complete',
'competitors_total': 2, 'competitors_done': 2, 'results': {'success': [{'name': 'Intrepid'}]},
'started_at': '2026-06-15T00:00:00Z',
})
response = client.get(
'/research/history/latest', query_string={'email': 'pm@gadventures.com'}, headers=api_headers
)
assert response.status_code == 200
assert response.json['id'] == latest['id']
# ── Research history / status / battlecards / comparable-trips ────
def test_research_status_unknown_job_404(client, api_headers):
response = client.get('/research/status/nope', headers=api_headers)
assert response.status_code == 404
def test_research_status_known_job(client, api_headers, monkeypatch):
import jobs
tenant = _make_tenant()
run = scrape_repository.create({
'tenant_id': tenant['id'], 'triggered_by': 'manual', 'status': 'running',
'competitors_total': 2, 'competitors_done': 1, 'results': {},
})
# Genuinely still executing — the reconciliation check must leave it alone.
monkeypatch.setattr(jobs.Job, 'fetch', lambda job_id, connection=None: types.SimpleNamespace(get_status=lambda: 'started'))
response = client.get(f'/research/status/{run["id"]}', headers=api_headers)
assert response.status_code == 200
assert response.json['status'] == 'running'
assert response.json['progress'] == {'total': 2, 'done': 1}
def test_research_status_reconciles_orphaned_job_to_cancelled(client, api_headers, monkeypatch):
# Regression test — a PM clicks Cancel, then the worker process
# handling the job is killed outright (e.g. a container rebuild)
# before run_research_job() ever reaches its cooperative cancel
# checkpoint. Without reconciliation, scrape_runs stays 'running'
# forever and the dashboard shows "Stopping…" indefinitely.
import jobs
tenant = _make_tenant()
run = scrape_repository.create({
'tenant_id': tenant['id'], 'triggered_by': 'manual', 'status': 'running',
'cancel_requested': True, 'competitors_total': 5, 'competitors_done': 3, 'results': {},
})
monkeypatch.setattr(jobs.Job, 'fetch', lambda job_id, connection=None: types.SimpleNamespace(get_status=lambda: 'failed'))
response = client.get(f'/research/status/{run["id"]}', headers=api_headers)
assert response.status_code == 200
assert response.json['status'] == 'cancelled'
updated = scrape_repository.get_by_id(run['id'])
assert updated['status'] == 'cancelled'
assert updated['completed_at'] is not None
def test_research_status_reconciles_orphaned_job_to_failed_without_cancel_request(client, api_headers, monkeypatch):
# Same orphaned-worker scenario, but the PM never clicked cancel —
# an uncaught exception or a killed worker should surface as a real
# failure, not silently stay 'running' forever either.
import jobs
tenant = _make_tenant()
run = scrape_repository.create({
'tenant_id': tenant['id'], 'triggered_by': 'manual', 'status': 'running',
'competitors_total': 5, 'competitors_done': 2, 'results': {},
})
monkeypatch.setattr(jobs.Job, 'fetch', lambda job_id, connection=None: types.SimpleNamespace(get_status=lambda: 'failed'))
response = client.get(f'/research/status/{run["id"]}', headers=api_headers)
assert response.status_code == 200
assert response.json['status'] == 'failed'
assert response.json['error']
def test_research_status_reconciles_when_rq_has_no_record_at_all(client, api_headers, monkeypatch):
import jobs
tenant = _make_tenant()
run = scrape_repository.create({
'tenant_id': tenant['id'], 'triggered_by': 'manual', 'status': 'running',
'cancel_requested': True, 'competitors_total': 3, 'competitors_done': 1, 'results': {},
})
def _raise_no_such_job(job_id, connection=None):
raise jobs.NoSuchJobError()
monkeypatch.setattr(jobs.Job, 'fetch', _raise_no_such_job)
response = client.get(f'/research/status/{run["id"]}', headers=api_headers)
assert response.status_code == 200
assert response.json['status'] == 'cancelled'
def test_research_status_does_not_touch_terminal_jobs(client, api_headers, monkeypatch):
"""A job already complete/failed/cancelled must never hit the RQ reconciliation path at all."""
import jobs
tenant = _make_tenant()
run = scrape_repository.create({
'tenant_id': tenant['id'], 'triggered_by': 'manual', 'status': 'complete',
'competitors_total': 1, 'competitors_done': 1, 'results': {'success': []},
})
def _fail_if_called(job_id, connection=None):
raise AssertionError('must not check RQ for an already-terminal job')
monkeypatch.setattr(jobs.Job, 'fetch', _fail_if_called)
response = client.get(f'/research/status/{run["id"]}', headers=api_headers)
assert response.status_code == 200
assert response.json['status'] == 'complete'
def test_research_history_detail_unknown_404(client, api_headers):
response = client.get('/research/history/nope', headers=api_headers)
assert response.status_code == 404
def test_research_cancel_known_job(client, api_headers, monkeypatch):
tenant = _make_tenant()
run = scrape_repository.create({
'tenant_id': tenant['id'], 'triggered_by': 'manual', 'status': 'running',
'competitors_total': 2, 'competitors_done': 1, 'results': {},
})
calls = []
monkeypatch.setattr('jobs.cancel_research_job', lambda job_id: calls.append(job_id) or True)
response = client.post(f'/research/cancel/{run["id"]}', headers=api_headers)
assert response.status_code == 200
assert response.json['status'] == 'cancelling'
assert calls == [run['id']]
def test_research_cancel_unknown_job_404(client, api_headers, monkeypatch):
monkeypatch.setattr('jobs.cancel_research_job', lambda job_id: False)
response = client.post('/research/cancel/nope', headers=api_headers)
assert response.status_code == 404
# ── Rate limiting — CORS preflight exemption ───────────────────────
def test_options_preflight_never_rate_limited(client):
"""
Regression test — the dashboard's 3s status-poll loop tripped a 429
on the browser's own CORS preflight (OPTIONS) within under a minute:
each cross-origin GET also fires an OPTIONS first, doubling counted
requests against the 30/minute default limit. OPTIONS must never be
rate limited — it carries no X-API-Key and isn't a real request.
"""
for _ in range(50): # comfortably past the 30/minute default limit
response = client.options('/research/status/anything')
assert response.status_code != 429
def test_get_requests_still_rate_limited(client, api_headers):
"""
The OPTIONS exemption must not accidentally exempt real requests too.
Uses /research/history rather than /research/status — the latter now
has its own override_defaults=True limit (see api.py) specifically
because it's polled, so it wouldn't trip the blanket default here.
"""
responses = [client.get('/research/history', query_string={'email': 'pm@test.com'}, headers=api_headers)
for _ in range(35)]
assert any(r.status_code == 429 for r in responses)
def test_research_status_survives_a_full_poll_cycle_worth_of_requests(client, api_headers):
"""
Regression test — a real test job's 20-minute poll loop (3s interval,
analysis_store.js's POLL_TIMEOUT_ATTEMPTS=400) tripped the blanket
200/hour default well before the job even finished, because
research_status had no override. 250 requests here comfortably
exceeds the old 200/hour default and must still all succeed under
the route's own 3000/hour override.
"""
responses = [client.get('/research/status/nope', headers=api_headers) for _ in range(250)]
assert all(r.status_code == 404 for r in responses) # 404 (unknown job), never 429
def test_research_history_for_tenant(client, api_headers):
tenant = _make_tenant()
scrape_repository.create({
'tenant_id': tenant['id'], 'triggered_by': 'manual', 'status': 'complete',
'competitors_total': 1, 'competitors_done': 1, 'results': {},
})
response = client.get('/research/history', query_string={'email': 'pm@gadventures.com'}, headers=api_headers)
assert response.status_code == 200
assert len(response.json) == 1
def test_research_history_excludes_per_competitor_bookkeeping_rows(client, api_headers):
# Regression test — analysis_service.run_competitor_analysis()'s
# refresh path creates its own scrape_runs row PER COMPETITOR purely
# to persist a content_hash for next time (hardcoded
# triggered_by='cron', competitors_total=1, results={}), regardless of
# what triggered the parent job. Without filtering these out, "last 5
# runs" showed misleading 1-competitor entries with no restorable
# results instead of the real batch run.
tenant = _make_tenant()
real_run = scrape_repository.create({
'tenant_id': tenant['id'], 'triggered_by': 'manual', 'status': 'complete',
'competitors_total': 5, 'competitors_done': 5,
'results': {'success': [{'name': 'Intrepid'}], 'failed': []},
})
for _ in range(5):
scrape_repository.create({
'tenant_id': tenant['id'], 'triggered_by': 'cron', 'status': 'complete',
'competitors_total': 1, 'competitors_done': 1, 'results': {},
'content_hash': 'somehash',
})
response = client.get('/research/history', query_string={'email': 'pm@gadventures.com'}, headers=api_headers)
assert response.status_code == 200
assert len(response.json) == 1
assert response.json[0]['id'] == real_run['id']
def test_research_history_latest_excludes_per_competitor_bookkeeping_rows(client, api_headers):
tenant = _make_tenant()
real_run = scrape_repository.create({
'tenant_id': tenant['id'], 'triggered_by': 'manual', 'status': 'complete',
'competitors_total': 5, 'competitors_done': 5,
'results': {'success': [{'name': 'Intrepid'}], 'failed': []},
'started_at': '2026-06-01T00:00:00Z',
})
# Created after the real run, same as the actual per-competitor loop —
# this is exactly the case that could previously win the "latest
# complete" lookup used by the Apps Script sidebar's pull integration.
scrape_repository.create({
'tenant_id': tenant['id'], 'triggered_by': 'cron', 'status': 'complete',
'competitors_total': 1, 'competitors_done': 1, 'results': {},
'content_hash': 'somehash', 'started_at': '2026-06-01T00:05:00Z',
})
response = client.get(
'/research/history/latest', query_string={'email': 'pm@gadventures.com'}, headers=api_headers
)
assert response.status_code == 200
assert response.json['id'] == real_run['id']
def test_battlecards_route(client, api_headers):
tenant = _make_tenant()
competitor = competitor_repository.create({
'tenant_id': tenant['id'], 'name': 'Intrepid', 'website': 'https://intrepidtravel.com',
'catalogue_url': 'https://intrepidtravel.com/adventures', 'primary_market': 'GB', 'active': True,
})
battlecard_repository.upsert(competitor['id'], {'tenant_id': tenant['id'], 'content': 'x', 'content_json': {}})
response = client.post('/battlecards', json={'email': 'pm@gadventures.com'}, headers=api_headers)
assert response.status_code == 200
assert response.json[0]['competitor_name'] == 'Intrepid'
def test_comparable_trips_route(client, api_headers):
tenant = _make_tenant()
comparable_repository.create({'tenant_id': tenant['id'], 'client_product': 'x', 'similarity_score': 0.8})
response = client.post('/comparable-trips', json={'email': 'pm@gadventures.com'}, headers=api_headers)
assert response.status_code == 200
assert len(response.json) == 1
# ── Alerts ─────────────────────────────────────────────────────────
def test_get_alerts_requires_session(client, api_headers):
response = client.get('/alerts', headers=api_headers)
assert response.status_code == 401
def test_get_alerts_returns_recent_for_session_tenant(client, api_headers, monkeypatch):
tenant = _make_tenant()
alert_repository.create({'tenant_id': tenant['id'], 'alert_type': 'price_change', 'message': 'x'})
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: tenant['id'])
response = client.get('/alerts', headers=api_headers)
assert response.status_code == 200
assert len(response.json) == 1
def test_get_alerts_unread_only(client, api_headers, monkeypatch):
tenant = _make_tenant()
alert_repository.create({'tenant_id': tenant['id'], 'alert_type': 'price_change', 'message': 'x', 'read': True})
alert_repository.create({'tenant_id': tenant['id'], 'alert_type': 'new_product', 'message': 'y', 'read': False})
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: tenant['id'])
response = client.get('/alerts', query_string={'unread_only': 'true'}, headers=api_headers)
assert response.status_code == 200
assert len(response.json) == 1
assert response.json[0]['message'] == 'y'
def test_mark_alert_read_requires_session(client, api_headers):
response = client.patch('/alerts/a1/read', headers=api_headers)
assert response.status_code == 401
def test_mark_alert_read_unknown_alert_404(client, api_headers, monkeypatch):
tenant = _make_tenant()
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: tenant['id'])
response = client.patch('/alerts/does-not-exist/read', headers=api_headers)
assert response.status_code == 404
def test_mark_alert_read_rejects_other_tenants_alert(client, api_headers, monkeypatch):
tenant = _make_tenant()
other_tenant = _make_tenant(domain='otherco.com', email='pm@otherco.com')
alert = alert_repository.create({'tenant_id': other_tenant['id'], 'alert_type': 'price_change', 'message': 'x'})
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: tenant['id'])
response = client.patch(f'/alerts/{alert["id"]}/read', headers=api_headers)
assert response.status_code == 403
def test_mark_alert_read_success(client, api_headers, monkeypatch):
tenant = _make_tenant()
alert = alert_repository.create({'tenant_id': tenant['id'], 'alert_type': 'price_change', 'message': 'x'})
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: tenant['id'])
response = client.patch(f'/alerts/{alert["id"]}/read', headers=api_headers)
assert response.status_code == 200
assert response.json['read'] is True
# ── Trip watchlist ─────────────────────────────────────────────────
def _make_competitor(tenant_id, **overrides):
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(overrides)
return competitor_repository.create(data)
def test_get_watchlist_requires_session(client, api_headers):
response = client.get('/watchlist', headers=api_headers)
assert response.status_code == 401
def test_get_watchlist_returns_tenant_items(client, api_headers, monkeypatch):
from services import watchlist_service
tenant = _make_tenant()
seat = seat_repository.create({'tenant_id': tenant['id'], 'email': 'pm@gadventures.com', 'active': True})
competitor = _make_competitor(tenant['id'])
watchlist_service.add_to_watchlist(tenant, seat, competitor['id'], 'https://intrepidtravel.com/peru', 'Peru Classic')
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: tenant['id'])
response = client.get('/watchlist', headers=api_headers)
assert response.status_code == 200
assert len(response.json) == 1
assert response.json[0]['watched_by_email'] == 'pm@gadventures.com'
def test_add_watchlist_item_requires_session(client, api_headers):
response = client.post('/watchlist', json={'competitor_id': 'c1', 'url': 'x'}, headers=api_headers)
assert response.status_code == 401
def test_add_watchlist_item_requires_fields(client, api_headers, monkeypatch):
tenant = _make_tenant()
seat = seat_repository.create({'tenant_id': tenant['id'], 'email': 'pm@gadventures.com', 'active': True})
monkeypatch.setattr('api.auth_service.get_seat_from_request', lambda req: (tenant['id'], seat))
response = client.post('/watchlist', json={}, headers=api_headers)
assert response.status_code == 400
assert response.json['code'] == 'missing_field'
def test_add_watchlist_item_unknown_competitor_404(client, api_headers, monkeypatch):
tenant = _make_tenant()
seat = seat_repository.create({'tenant_id': tenant['id'], 'email': 'pm@gadventures.com', 'active': True})
monkeypatch.setattr('api.auth_service.get_seat_from_request', lambda req: (tenant['id'], seat))
response = client.post(
'/watchlist', json={'competitor_id': 'does-not-exist', 'url': 'https://x.com/y'}, headers=api_headers
)
assert response.status_code == 404
def test_add_watchlist_item_rejects_other_tenants_competitor(client, api_headers, monkeypatch):
tenant = _make_tenant()
other_tenant = _make_tenant(domain='otherco.com', email='pm@otherco.com')
competitor = _make_competitor(other_tenant['id'])
seat = seat_repository.create({'tenant_id': tenant['id'], 'email': 'pm@gadventures.com', 'active': True})
monkeypatch.setattr('api.auth_service.get_seat_from_request', lambda req: (tenant['id'], seat))
response = client.post(
'/watchlist', json={'competitor_id': competitor['id'], 'url': 'https://intrepidtravel.com/peru'},
headers=api_headers
)
assert response.status_code == 404
def test_add_watchlist_item_success(client, api_headers, monkeypatch):
tenant = _make_tenant()
seat = seat_repository.create({'tenant_id': tenant['id'], 'email': 'pm@gadventures.com', 'active': True})
competitor = _make_competitor(tenant['id'])
monkeypatch.setattr('api.auth_service.get_seat_from_request', lambda req: (tenant['id'], seat))
response = client.post(
'/watchlist',
json={'competitor_id': competitor['id'], 'url': 'https://intrepidtravel.com/peru', 'trip_name': 'Peru Classic'},
headers=api_headers
)
assert response.status_code == 201
assert response.json['url'] == 'https://intrepidtravel.com/peru'
assert response.json['seat_id'] == seat['id']
def test_add_watchlist_item_blocked_at_cap(client, api_headers, monkeypatch):
tenant = _make_tenant() # analyse tier — cap is 1/seat
seat = seat_repository.create({'tenant_id': tenant['id'], 'email': 'pm@gadventures.com', 'active': True})
competitor = _make_competitor(tenant['id'])
monkeypatch.setattr('api.auth_service.get_seat_from_request', lambda req: (tenant['id'], seat))
client.post(
'/watchlist', json={'competitor_id': competitor['id'], 'url': 'https://intrepidtravel.com/peru'},
headers=api_headers
)
response = client.post(
'/watchlist', json={'competitor_id': competitor['id'], 'url': 'https://intrepidtravel.com/vietnam'},
headers=api_headers
)
assert response.status_code == 400
assert response.json['code'] == 'watchlist_full'
def test_remove_watchlist_item_requires_session(client, api_headers):
response = client.delete('/watchlist/w1', headers=api_headers)
assert response.status_code == 401
def test_remove_watchlist_item_unknown_404(client, api_headers, monkeypatch):
tenant = _make_tenant()
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: tenant['id'])
response = client.delete('/watchlist/does-not-exist', headers=api_headers)
assert response.status_code == 404
def test_remove_watchlist_item_rejects_other_tenants_item(client, api_headers, monkeypatch):
from services import watchlist_service
tenant = _make_tenant()
other_tenant = _make_tenant(domain='otherco.com', email='pm@otherco.com')
other_seat = seat_repository.create({'tenant_id': other_tenant['id'], 'email': 'pm@otherco.com', 'active': True})
other_competitor = _make_competitor(other_tenant['id'])
added = watchlist_service.add_to_watchlist(
other_tenant, other_seat, other_competitor['id'], 'https://intrepidtravel.com/peru', 'Peru'
)
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: tenant['id'])
response = client.delete(f'/watchlist/{added["item"]["id"]}', headers=api_headers)
assert response.status_code == 404
def test_remove_watchlist_item_success_frees_the_slot(client, api_headers, monkeypatch):
from services import watchlist_service
tenant = _make_tenant()
seat = seat_repository.create({'tenant_id': tenant['id'], 'email': 'pm@gadventures.com', 'active': True})
competitor = _make_competitor(tenant['id'])
added = watchlist_service.add_to_watchlist(
tenant, seat, competitor['id'], 'https://intrepidtravel.com/peru', 'Peru'
)
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: tenant['id'])
response = client.delete(f'/watchlist/{added["item"]["id"]}', headers=api_headers)
assert response.status_code == 200
monkeypatch.setattr('api.auth_service.get_seat_from_request', lambda req: (tenant['id'], seat))
retry = client.post(
'/watchlist', json={'competitor_id': competitor['id'], 'url': 'https://intrepidtravel.com/vietnam'},
headers=api_headers
)
assert retry.status_code == 201
def test_preview_prompt(client, api_headers, monkeypatch):
_install_fake_module(
monkeypatch, 'industries.adventure_travel.prompt',
build_prompt=lambda *a, **k: 'a fake built prompt'
)
response = client.post('/preview-prompt', json={
'productName': 'Inca Trail', 'destination': 'Peru', 'duration': 15, 'travelStyle': 'Classic',
}, headers=api_headers)
assert response.status_code == 200
assert response.json['prompt'] == 'a fake built prompt'
# ── Auth password reset proxy routes ────────────────────────────────
def test_request_password_reset_missing_email(client, api_headers):
response = client.post('/auth/request-password-reset', json={}, headers=api_headers)
assert response.status_code == 400
def test_request_password_reset_ok(client, api_headers, monkeypatch):
monkeypatch.setattr('api.requests.post', lambda *a, **k: types.SimpleNamespace(status_code=204))
response = client.post(
'/auth/request-password-reset', json={'email': 'pm@gadventures.com'}, headers=api_headers
)
assert response.status_code == 200
def test_confirm_password_reset_missing_fields(client, api_headers):
response = client.post('/auth/confirm-password-reset', json={'token': 'x'}, headers=api_headers)
assert response.status_code == 400
def test_confirm_password_reset_invalid_token(client, api_headers, monkeypatch):
monkeypatch.setattr('api.requests.post', lambda *a, **k: types.SimpleNamespace(status_code=400))
response = client.post('/auth/confirm-password-reset', json={
'token': 'bad', 'password': 'newpass123', 'passwordConfirm': 'newpass123',
}, headers=api_headers)
assert response.status_code == 400
assert response.json['code'] == 'reset_failed'
def test_confirm_password_reset_ok(client, api_headers, monkeypatch):
monkeypatch.setattr('api.requests.post', lambda *a, **k: types.SimpleNamespace(status_code=200))
response = client.post('/auth/confirm-password-reset', json={
'token': 'good', 'password': 'newpass123', 'passwordConfirm': 'newpass123',
}, headers=api_headers)
assert response.status_code == 200
# ── Stripe webhook ──────────────────────────────────────────────────
def test_stripe_webhook_invalid_signature_rejected(client, monkeypatch):
import stripe as stripe_module
def _raise(*a, **k):
raise ValueError('bad signature')
monkeypatch.setattr(stripe_module.Webhook, 'construct_event', staticmethod(_raise))
response = client.post(
'/stripe/webhook', data=b'{}', headers={'Stripe-Signature': 'bad'}
)
assert response.status_code == 400
assert response.json['code'] == 'invalid_signature'
def test_stripe_webhook_dispatches_checkout_completed(client, monkeypatch):
import stripe as stripe_module
tenant = _make_tenant()
fake_event = {
'type': 'checkout.session.completed',
'data': {'object': {
'metadata': {'tenant_id': tenant['id'], 'target_tier': 'discover'},
'subscription': 'sub_1',
}},
}
monkeypatch.setattr(stripe_module.Webhook, 'construct_event', staticmethod(lambda *a, **k: fake_event))
response = client.post('/stripe/webhook', data=b'{}', headers={'Stripe-Signature': 'ok'})
assert response.status_code == 200
assert tenant_repository.get_by_id(tenant['id'])['tier'] == 'discover'
def test_stripe_webhook_dispatches_subscription_deleted(client, monkeypatch):
import stripe as stripe_module
tenant = _make_tenant(stripe_customer_id='cus_123')
fake_event = {
'type': 'customer.subscription.deleted',
'data': {'object': {'customer': 'cus_123'}},
}
monkeypatch.setattr(stripe_module.Webhook, 'construct_event', staticmethod(lambda *a, **k: fake_event))
response = client.post('/stripe/webhook', data=b'{}', headers={'Stripe-Signature': 'ok'})
assert response.status_code == 200
assert tenant_repository.get_by_id(tenant['id'])['status'] == 'inactive'
def test_stripe_webhook_unhandled_event_type_ok(client, monkeypatch):
import stripe as stripe_module
fake_event = {'type': 'invoice.paid', 'data': {'object': {}}}
monkeypatch.setattr(stripe_module.Webhook, 'construct_event', staticmethod(lambda *a, **k: fake_event))
response = client.post('/stripe/webhook', data=b'{}', headers={'Stripe-Signature': 'ok'})
assert response.status_code == 200