725 lines
30 KiB
Python
725 lines
30 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 sys
|
|
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):
|
|
"""
|
|
Ensures `dotted_name` resolves to a module, then uses
|
|
monkeypatch.setattr for every attribute — required now that
|
|
core.extractor/industries.adventure_travel.prompt are real Phase 2
|
|
modules: a raw setattr would permanently corrupt the shared module
|
|
for every later test since monkeypatch never recorded it.
|
|
"""
|
|
parts = dotted_name.split('.')
|
|
for i in range(1, len(parts) + 1):
|
|
partial = '.'.join(parts[:i])
|
|
if partial not in sys.modules:
|
|
monkeypatch.setitem(sys.modules, partial, types.ModuleType(partial))
|
|
for key, value in attrs.items():
|
|
monkeypatch.setattr(sys.modules[dotted_name], 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_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_and_deregisters(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, 'firecrawl_monitor_id': 'mon_123',
|
|
})
|
|
deregistered = []
|
|
monkeypatch.setattr('api.requests.delete', lambda url, timeout: deregistered.append(url))
|
|
|
|
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
|
|
assert deregistered
|
|
|
|
|
|
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_marks_inactive_and_deactivates_competitors(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,
|
|
})
|
|
seat_repository.create({'tenant_id': tenant['id'], 'email': 'pm@gadventures.com', 'active': True})
|
|
|
|
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'])['status'] == 'inactive'
|
|
assert competitor_repository.get_by_id(competitor['id'])['active'] is False
|
|
assert seat_repository.list_for_tenant(tenant['id']) == []
|
|
|
|
|
|
# ── 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_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_monitor_webhook_flags_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.post(
|
|
'/internal/monitor-webhook', json={'url': 'https://intrepidtravel.com'}, headers=api_headers
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert competitor_repository.get_by_id(competitor['id'])['change_detected'] is True
|
|
|
|
|
|
def test_internal_monitor_webhook_unknown_url_is_noop(client, api_headers):
|
|
response = client.post(
|
|
'/internal/monitor-webhook', json={'url': 'https://unknown.com'}, headers=api_headers
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
|
|
def test_internal_monitor_register_and_deregister(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, 'firecrawl_monitor_id': 'mon_1',
|
|
})
|
|
monkeypatch.setattr('api.requests.post', lambda *a, **k: types.SimpleNamespace(json=lambda: {'id': 'mon_2'}))
|
|
monkeypatch.setattr('api.requests.delete', lambda *a, **k: None)
|
|
|
|
r1 = client.post(f'/internal/monitor/register/{competitor["id"]}', headers=api_headers)
|
|
assert r1.status_code == 200
|
|
|
|
r2 = client.post(f'/internal/monitor/deregister/{competitor["id"]}', headers=api_headers)
|
|
assert r2.status_code == 200
|
|
|
|
|
|
def test_internal_monitor_register_unknown_competitor_404(client, api_headers):
|
|
response = client.post('/internal/monitor/register/nope', headers=api_headers)
|
|
assert response.status_code == 404
|
|
|
|
|
|
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
|
|
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',
|
|
})
|
|
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'] == 1
|
|
|
|
|
|
def test_internal_match_comparable(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}
|
|
]
|
|
)
|
|
|
|
response = client.post(
|
|
f'/internal/match-comparable/{tenant["id"]}', json={'client_trips': []}, headers=api_headers
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json['matches_created'] == 1
|
|
assert len(comparable_repository.list_for_tenant(tenant['id'])) == 1
|
|
|
|
|
|
# ── 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):
|
|
tenant = _make_tenant()
|
|
run = scrape_repository.create({
|
|
'tenant_id': tenant['id'], 'triggered_by': 'manual', 'status': 'running',
|
|
'competitors_total': 2, 'competitors_done': 1, 'results': {},
|
|
})
|
|
response = client.get(f'/research/status/{run["id"]}', headers=api_headers)
|
|
assert response.status_code == 200
|
|
assert response.json['progress'] == {'total': 2, 'done': 1}
|
|
|
|
|
|
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_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_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
|
|
|
|
|
|
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
|