550 lines
23 KiB
Python
550 lines
23 KiB
Python
"""
|
|
jobs.run_research_job — per-competitor failure isolation, status
|
|
transitions, and the slow/stuck Gotify alert thresholds from §29.
|
|
tests/ is the conftest-adjacent location; this file lives under
|
|
tests/services/ purely for directory tidiness (jobs.py itself is a
|
|
top-level module, not part of services/).
|
|
"""
|
|
import jobs
|
|
from repositories.scrape_repository import scrape_repository
|
|
from repositories.competitor_repository import competitor_repository
|
|
from repositories.tenant_repository import tenant_repository
|
|
|
|
|
|
def _setup(max_seats=5):
|
|
tenant = tenant_repository.create({
|
|
'name': 'G Adventures', 'domain': 'gadventures.com', 'tier': 'analyse',
|
|
'status': 'active', 'max_seats': max_seats,
|
|
})
|
|
competitor = 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,
|
|
})
|
|
run = scrape_repository.create({
|
|
'tenant_id': tenant['id'], 'triggered_by': 'manual', 'status': 'pending',
|
|
'competitors_total': 1, 'competitors_done': 0, 'results': {},
|
|
})
|
|
return tenant, competitor, run
|
|
|
|
|
|
def test_run_research_job_marks_complete_on_success(monkeypatch):
|
|
tenant, competitor, run = _setup()
|
|
monkeypatch.setattr(
|
|
'services.analysis_service.run_competitor_analysis',
|
|
lambda tenant_id, comp, ctx: {'path': 'fast', 'result': {'tripName': 'Peru Classic'}}
|
|
)
|
|
gotify_calls = []
|
|
monkeypatch.setattr('services.alert_service.send_gotify', lambda **kw: gotify_calls.append(kw))
|
|
|
|
jobs.run_research_job(run['id'], {'tenant_id': tenant['id'], 'competitors': [{'id': competitor['id']}]})
|
|
|
|
updated = scrape_repository.get_by_id(run['id'])
|
|
assert updated['status'] == 'complete'
|
|
assert updated['competitors_done'] == 1
|
|
assert updated['results']['success'][0]['name'] == 'Intrepid Travel'
|
|
assert gotify_calls == [] # fast completion, no slow/stuck alert
|
|
|
|
|
|
def test_run_research_job_threads_confirmed_url_per_competitor(monkeypatch):
|
|
# Regression test — entry['url'] (the URL confirmed at
|
|
# UrlConfirmation.vue, whether a Firecrawl match or a manual swap) used
|
|
# to be silently discarded; only entry['id'] was ever read.
|
|
tenant, competitor, run = _setup()
|
|
captured_contexts = []
|
|
monkeypatch.setattr(
|
|
'services.analysis_service.run_competitor_analysis',
|
|
lambda tenant_id, comp, ctx: captured_contexts.append(ctx) or {'path': 'refresh', 'result': {}}
|
|
)
|
|
monkeypatch.setattr('services.alert_service.send_gotify', lambda **kw: None)
|
|
|
|
jobs.run_research_job(run['id'], {
|
|
'tenant_id': tenant['id'],
|
|
'competitors': [{'id': competitor['id'], 'name': competitor['name'], 'url': 'https://intrepidtravel.com/peru-classic-15-days'}],
|
|
})
|
|
|
|
assert captured_contexts[0]['confirmed_url'] == 'https://intrepidtravel.com/peru-classic-15-days'
|
|
|
|
|
|
def test_run_research_job_confirmed_url_blank_normalizes_to_none(monkeypatch):
|
|
tenant, competitor, run = _setup()
|
|
captured_contexts = []
|
|
monkeypatch.setattr(
|
|
'services.analysis_service.run_competitor_analysis',
|
|
lambda tenant_id, comp, ctx: captured_contexts.append(ctx) or {'path': 'refresh', 'result': {}}
|
|
)
|
|
monkeypatch.setattr('services.alert_service.send_gotify', lambda **kw: None)
|
|
|
|
jobs.run_research_job(run['id'], {
|
|
'tenant_id': tenant['id'],
|
|
'competitors': [{'id': competitor['id'], 'name': competitor['name'], 'url': ' '}],
|
|
})
|
|
|
|
assert captured_contexts[0]['confirmed_url'] is None
|
|
|
|
|
|
def test_run_research_job_threads_force_refresh_into_context(monkeypatch):
|
|
# context is hand-built from `data` in run_research_job(), not `data`
|
|
# itself — a regression guard against a future refactor silently
|
|
# dropping a key, the same class of bug that would have hidden
|
|
# tab_type/product_name if unguarded.
|
|
tenant, competitor, run = _setup()
|
|
captured_contexts = []
|
|
monkeypatch.setattr(
|
|
'services.analysis_service.run_competitor_analysis',
|
|
lambda tenant_id, comp, ctx: captured_contexts.append(ctx) or {'path': 'refresh', 'result': {}}
|
|
)
|
|
monkeypatch.setattr('services.alert_service.send_gotify', lambda **kw: None)
|
|
|
|
jobs.run_research_job(run['id'], {
|
|
'tenant_id': tenant['id'], 'competitors': [{'id': competitor['id']}], 'force_refresh': True,
|
|
})
|
|
|
|
assert captured_contexts[0]['force_refresh'] is True
|
|
|
|
|
|
def test_run_research_job_threads_real_tenant_company_name_into_context(monkeypatch):
|
|
# Regression test — build_prompt() used to hardcode "G Adventures"
|
|
# regardless of which tenant was actually running the analysis.
|
|
# context['company_name'] must reflect this tenant's real name.
|
|
tenant = tenant_repository.create({
|
|
'name': 'Wild Horizons Travel', 'domain': 'wildhorizons.example', 'tier': 'analyse',
|
|
'status': 'active', 'max_seats': 5,
|
|
})
|
|
competitor = 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,
|
|
})
|
|
run = scrape_repository.create({
|
|
'tenant_id': tenant['id'], 'triggered_by': 'manual', 'status': 'pending',
|
|
'competitors_total': 1, 'competitors_done': 0, 'results': {},
|
|
})
|
|
captured_contexts = []
|
|
monkeypatch.setattr(
|
|
'services.analysis_service.run_competitor_analysis',
|
|
lambda tenant_id, comp, ctx: captured_contexts.append(ctx) or {'path': 'refresh', 'result': {}}
|
|
)
|
|
monkeypatch.setattr('services.alert_service.send_gotify', lambda **kw: None)
|
|
|
|
jobs.run_research_job(run['id'], {'tenant_id': tenant['id'], 'competitors': [{'id': competitor['id']}]})
|
|
|
|
assert captured_contexts[0]['company_name'] == 'Wild Horizons Travel'
|
|
|
|
|
|
def test_run_research_job_threads_own_trip_result_into_context(monkeypatch):
|
|
tenant, competitor, run = _setup()
|
|
captured_contexts = []
|
|
monkeypatch.setattr(
|
|
'services.analysis_service.run_competitor_analysis',
|
|
lambda tenant_id, comp, ctx: captured_contexts.append(ctx) or {'path': 'refresh', 'result': {}}
|
|
)
|
|
monkeypatch.setattr('services.alert_service.send_gotify', lambda **kw: None)
|
|
monkeypatch.setattr(
|
|
'services.embedding_service.scrape_own_trip',
|
|
lambda tenant_id, website_url, ctx: {'tripName': 'Inca Trail Classic', 'majorityPrice': 1999},
|
|
)
|
|
|
|
jobs.run_research_job(run['id'], {
|
|
'tenant_id': tenant['id'], 'competitors': [{'id': competitor['id']}],
|
|
'own_trip_url': 'https://wildhorizons.com/inca-trail',
|
|
})
|
|
|
|
assert captured_contexts[0]['own_trip'] == {'tripName': 'Inca Trail Classic', 'majorityPrice': 1999}
|
|
|
|
|
|
def test_run_research_job_surfaces_own_trip_in_persisted_results(monkeypatch):
|
|
# Regression test — own_trip was computed and fed into build_prompt()
|
|
# but never actually returned to the dashboard, so there was no way
|
|
# for a PM to confirm their own site was really scraped and used.
|
|
tenant, competitor, run = _setup()
|
|
monkeypatch.setattr(
|
|
'services.analysis_service.run_competitor_analysis',
|
|
lambda tenant_id, comp, ctx: {'path': 'refresh', 'result': {}}
|
|
)
|
|
monkeypatch.setattr('services.alert_service.send_gotify', lambda **kw: None)
|
|
monkeypatch.setattr(
|
|
'services.embedding_service.scrape_own_trip',
|
|
lambda tenant_id, website_url, ctx: {'tripName': 'Inca Trail Classic', 'majorityPrice': 1999, 'cached': False},
|
|
)
|
|
|
|
jobs.run_research_job(run['id'], {
|
|
'tenant_id': tenant['id'], 'competitors': [{'id': competitor['id']}],
|
|
'own_trip_url': 'https://wildhorizons.com/inca-trail',
|
|
})
|
|
|
|
updated = scrape_repository.get_by_id(run['id'])
|
|
assert updated['results']['own_trip'] == {'tripName': 'Inca Trail Classic', 'majorityPrice': 1999, 'cached': False}
|
|
|
|
|
|
def test_run_research_job_own_trip_is_none_in_results_without_own_trip_url(monkeypatch):
|
|
tenant, competitor, run = _setup()
|
|
monkeypatch.setattr(
|
|
'services.analysis_service.run_competitor_analysis',
|
|
lambda tenant_id, comp, ctx: {'path': 'refresh', 'result': {}}
|
|
)
|
|
monkeypatch.setattr('services.alert_service.send_gotify', lambda **kw: None)
|
|
|
|
jobs.run_research_job(run['id'], {'tenant_id': tenant['id'], 'competitors': [{'id': competitor['id']}]})
|
|
|
|
updated = scrape_repository.get_by_id(run['id'])
|
|
assert updated['results']['own_trip'] is None
|
|
|
|
|
|
def test_run_research_job_own_trip_defaults_to_none_without_own_trip_url(monkeypatch):
|
|
tenant, competitor, run = _setup()
|
|
captured_contexts = []
|
|
monkeypatch.setattr(
|
|
'services.analysis_service.run_competitor_analysis',
|
|
lambda tenant_id, comp, ctx: captured_contexts.append(ctx) or {'path': 'refresh', 'result': {}}
|
|
)
|
|
monkeypatch.setattr('services.alert_service.send_gotify', lambda **kw: None)
|
|
|
|
jobs.run_research_job(run['id'], {'tenant_id': tenant['id'], 'competitors': [{'id': competitor['id']}]})
|
|
|
|
assert captured_contexts[0]['own_trip'] is None
|
|
|
|
|
|
def test_run_research_job_own_trip_failure_does_not_fail_the_batch(monkeypatch):
|
|
# A side-effect failure (site blocked, extraction error) must never
|
|
# fail the whole batch — CLAUDE.md §18 resilience rule.
|
|
tenant, competitor, run = _setup()
|
|
captured_contexts = []
|
|
monkeypatch.setattr(
|
|
'services.analysis_service.run_competitor_analysis',
|
|
lambda tenant_id, comp, ctx: captured_contexts.append(ctx) or {'path': 'refresh', 'result': {}}
|
|
)
|
|
monkeypatch.setattr('services.alert_service.send_gotify', lambda **kw: None)
|
|
|
|
def _raise(tenant_id, website_url, ctx):
|
|
raise RuntimeError('boom')
|
|
monkeypatch.setattr('services.embedding_service.scrape_own_trip', _raise)
|
|
|
|
jobs.run_research_job(run['id'], {
|
|
'tenant_id': tenant['id'], 'competitors': [{'id': competitor['id']}],
|
|
'own_trip_url': 'https://wildhorizons.com/inca-trail',
|
|
})
|
|
|
|
assert captured_contexts[0]['own_trip'] is None
|
|
updated = scrape_repository.get_by_id(run['id'])
|
|
assert updated['status'] == 'complete'
|
|
|
|
|
|
def test_run_research_job_defaults_force_refresh_to_false(monkeypatch):
|
|
tenant, competitor, run = _setup()
|
|
captured_contexts = []
|
|
monkeypatch.setattr(
|
|
'services.analysis_service.run_competitor_analysis',
|
|
lambda tenant_id, comp, ctx: captured_contexts.append(ctx) or {'path': 'fast', 'result': {}}
|
|
)
|
|
monkeypatch.setattr('services.alert_service.send_gotify', lambda **kw: None)
|
|
|
|
jobs.run_research_job(run['id'], {'tenant_id': tenant['id'], 'competitors': [{'id': competitor['id']}]})
|
|
|
|
assert captured_contexts[0]['force_refresh'] is False
|
|
|
|
|
|
def test_run_research_job_regenerates_battlecard_on_refresh_path_only(monkeypatch):
|
|
tenant, competitor, run = _setup()
|
|
battlecard_calls = []
|
|
monkeypatch.setattr(
|
|
'services.battlecard_service.generate_battlecard',
|
|
lambda competitor_id, tenant_id: battlecard_calls.append(competitor_id)
|
|
)
|
|
monkeypatch.setattr('services.alert_service.send_gotify', lambda **kw: None)
|
|
monkeypatch.setattr(
|
|
'services.analysis_service.run_competitor_analysis',
|
|
lambda tenant_id, comp, ctx: {'path': 'fast', 'result': {}}
|
|
)
|
|
|
|
jobs.run_research_job(run['id'], {'tenant_id': tenant['id'], 'competitors': [{'id': competitor['id']}]})
|
|
|
|
assert battlecard_calls == [] # fast path — no new data, no regeneration
|
|
|
|
|
|
def test_run_research_job_regenerates_battlecard_on_refresh_success(monkeypatch):
|
|
tenant, competitor, run = _setup()
|
|
battlecard_calls = []
|
|
monkeypatch.setattr(
|
|
'services.battlecard_service.generate_battlecard',
|
|
lambda competitor_id, tenant_id: battlecard_calls.append(competitor_id)
|
|
)
|
|
monkeypatch.setattr('services.alert_service.send_gotify', lambda **kw: None)
|
|
monkeypatch.setattr(
|
|
'services.analysis_service.run_competitor_analysis',
|
|
lambda tenant_id, comp, ctx: {'path': 'refresh', 'result': {}, 'product': {'id': 'p1'}}
|
|
)
|
|
|
|
jobs.run_research_job(run['id'], {'tenant_id': tenant['id'], 'competitors': [{'id': competitor['id']}]})
|
|
|
|
assert battlecard_calls == [competitor['id']]
|
|
|
|
|
|
def test_run_research_job_skips_battlecard_when_refresh_unchanged(monkeypatch):
|
|
tenant, competitor, run = _setup()
|
|
battlecard_calls = []
|
|
monkeypatch.setattr(
|
|
'services.battlecard_service.generate_battlecard',
|
|
lambda competitor_id, tenant_id: battlecard_calls.append(competitor_id)
|
|
)
|
|
monkeypatch.setattr('services.alert_service.send_gotify', lambda **kw: None)
|
|
monkeypatch.setattr(
|
|
'services.analysis_service.run_competitor_analysis',
|
|
lambda tenant_id, comp, ctx: {'path': 'refresh', 'result': {}, 'unchanged': True}
|
|
)
|
|
|
|
jobs.run_research_job(run['id'], {'tenant_id': tenant['id'], 'competitors': [{'id': competitor['id']}]})
|
|
|
|
assert battlecard_calls == [] # content-hash found no diff — nothing new to regenerate from
|
|
|
|
|
|
def test_run_research_job_battlecard_failure_does_not_fail_job(monkeypatch):
|
|
tenant, competitor, run = _setup()
|
|
monkeypatch.setattr(
|
|
'services.battlecard_service.generate_battlecard',
|
|
lambda competitor_id, tenant_id: (_ for _ in ()).throw(RuntimeError('extraction down'))
|
|
)
|
|
monkeypatch.setattr('services.alert_service.send_gotify', lambda **kw: None)
|
|
monkeypatch.setattr(
|
|
'services.analysis_service.run_competitor_analysis',
|
|
lambda tenant_id, comp, ctx: {'path': 'refresh', 'result': {}, 'product': {'id': 'p1'}}
|
|
)
|
|
|
|
jobs.run_research_job(run['id'], {'tenant_id': tenant['id'], 'competitors': [{'id': competitor['id']}]})
|
|
|
|
updated = scrape_repository.get_by_id(run['id'])
|
|
assert updated['status'] == 'complete'
|
|
assert len(updated['results']['success']) == 1
|
|
|
|
|
|
def test_run_research_job_isolates_one_competitor_failure(monkeypatch):
|
|
tenant = tenant_repository.create({
|
|
'name': 'G Adventures', 'domain': 'gadventures.com', 'tier': 'analyse',
|
|
'status': 'active', 'max_seats': 5,
|
|
})
|
|
good = competitor_repository.create({
|
|
'tenant_id': tenant['id'], 'name': 'Exodus', 'website': 'https://exodustravels.com',
|
|
'catalogue_url': 'https://exodustravels.com/trips', 'primary_market': 'GB', 'active': True,
|
|
})
|
|
bad = 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,
|
|
})
|
|
run = scrape_repository.create({
|
|
'tenant_id': tenant['id'], 'triggered_by': 'manual', 'status': 'pending',
|
|
'competitors_total': 2, 'competitors_done': 0, 'results': {},
|
|
})
|
|
|
|
def _fake_analysis(tenant_id, comp, ctx):
|
|
if comp['name'] == 'Flash Pack':
|
|
raise ValueError('scrape_blocked')
|
|
return {'path': 'fast', 'result': {}}
|
|
|
|
monkeypatch.setattr('services.analysis_service.run_competitor_analysis', _fake_analysis)
|
|
monkeypatch.setattr('services.alert_service.send_gotify', lambda **kw: None)
|
|
|
|
jobs.run_research_job(run['id'], {
|
|
'tenant_id': tenant['id'], 'competitors': [{'id': good['id']}, {'id': bad['id']}]
|
|
})
|
|
|
|
updated = scrape_repository.get_by_id(run['id'])
|
|
assert updated['status'] == 'complete' # at least one success → overall complete
|
|
assert len(updated['results']['success']) == 1
|
|
assert len(updated['results']['failed']) == 1
|
|
assert updated['results']['failed'][0]['name'] == 'Flash Pack'
|
|
|
|
|
|
def test_run_research_job_marks_failed_when_all_competitors_fail(monkeypatch):
|
|
tenant, competitor, run = _setup()
|
|
monkeypatch.setattr(
|
|
'services.analysis_service.run_competitor_analysis',
|
|
lambda *a, **k: (_ for _ in ()).throw(ValueError('extract_failed'))
|
|
)
|
|
gotify_calls = []
|
|
monkeypatch.setattr('services.alert_service.send_gotify', lambda **kw: gotify_calls.append(kw))
|
|
|
|
jobs.run_research_job(run['id'], {'tenant_id': tenant['id'], 'competitors': [{'id': competitor['id']}]})
|
|
|
|
updated = scrape_repository.get_by_id(run['id'])
|
|
assert updated['status'] == 'failed'
|
|
assert any('Analysis job failed' in c['title'] for c in gotify_calls)
|
|
|
|
|
|
def test_run_research_job_fires_slow_alert_past_threshold(monkeypatch):
|
|
tenant, competitor, run = _setup()
|
|
monkeypatch.setattr(
|
|
'services.analysis_service.run_competitor_analysis',
|
|
lambda *a, **k: {'path': 'fast', 'result': {}}
|
|
)
|
|
gotify_calls = []
|
|
monkeypatch.setattr('services.alert_service.send_gotify', lambda **kw: gotify_calls.append(kw))
|
|
|
|
# Force the elapsed-time calculation past the slow threshold without
|
|
# an actual 5-minute sleep — patch time.time() to jump forward
|
|
# between the job's start and end reads.
|
|
real_time = jobs.time.time
|
|
times = iter([real_time(), real_time() + jobs.JOB_SLOW_SECONDS + 5])
|
|
monkeypatch.setattr(jobs.time, 'time', lambda: next(times))
|
|
|
|
jobs.run_research_job(run['id'], {'tenant_id': tenant['id'], 'competitors': [{'id': competitor['id']}]})
|
|
|
|
assert any('Slow job detected' in c['title'] for c in gotify_calls)
|
|
|
|
|
|
def test_run_research_job_handles_unknown_competitor_id(monkeypatch):
|
|
tenant = tenant_repository.create({
|
|
'name': 'G Adventures', 'domain': 'gadventures.com', 'tier': 'analyse',
|
|
'status': 'active', 'max_seats': 5,
|
|
})
|
|
run = scrape_repository.create({
|
|
'tenant_id': tenant['id'], 'triggered_by': 'manual', 'status': 'pending',
|
|
'competitors_total': 1, 'competitors_done': 0, 'results': {},
|
|
})
|
|
monkeypatch.setattr('services.alert_service.send_gotify', lambda **kw: None)
|
|
|
|
jobs.run_research_job(run['id'], {'tenant_id': tenant['id'], 'competitors': [{'id': 'does-not-exist'}]})
|
|
|
|
updated = scrape_repository.get_by_id(run['id'])
|
|
assert updated['status'] == 'failed'
|
|
assert updated['results']['failed'][0]['error'] == 'Competitor not found'
|
|
|
|
|
|
def test_run_research_job_stops_early_when_cancel_requested_from_the_start(monkeypatch):
|
|
tenant, competitor, run = _setup()
|
|
scrape_repository.update(run['id'], {'cancel_requested': True})
|
|
calls = []
|
|
monkeypatch.setattr(
|
|
'services.analysis_service.run_competitor_analysis',
|
|
lambda *a, **k: calls.append(1) or {'path': 'fast', 'result': {}}
|
|
)
|
|
gotify_calls = []
|
|
monkeypatch.setattr('services.alert_service.send_gotify', lambda **kw: gotify_calls.append(kw))
|
|
|
|
jobs.run_research_job(run['id'], {'tenant_id': tenant['id'], 'competitors': [{'id': competitor['id']}]})
|
|
|
|
updated = scrape_repository.get_by_id(run['id'])
|
|
assert updated['status'] == 'cancelled'
|
|
assert calls == [] # never even attempted the one competitor
|
|
assert gotify_calls == [] # cancellation isn't a failure or slow/stuck condition
|
|
|
|
|
|
def test_run_research_job_cancellation_preserves_prior_progress(monkeypatch):
|
|
tenant = tenant_repository.create({
|
|
'name': 'G Adventures', 'domain': 'gadventures.com', 'tier': 'analyse',
|
|
'status': 'active', 'max_seats': 5,
|
|
})
|
|
first = competitor_repository.create({
|
|
'tenant_id': tenant['id'], 'name': 'Exodus', 'website': 'https://exodustravels.com',
|
|
'catalogue_url': 'https://exodustravels.com/trips', 'primary_market': 'GB', 'active': True,
|
|
})
|
|
second = 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,
|
|
})
|
|
run = scrape_repository.create({
|
|
'tenant_id': tenant['id'], 'triggered_by': 'manual', 'status': 'pending',
|
|
'competitors_total': 2, 'competitors_done': 0, 'results': {},
|
|
})
|
|
|
|
# Simulates the PM clicking Cancel while the first competitor is
|
|
# still being analysed — cancel_requested flips mid-job, and the
|
|
# checkpoint before the *second* competitor should catch it. Flips
|
|
# the flag directly (rather than via cancel_research_job(), which
|
|
# would need a real Redis connection to fetch the RQ job record) —
|
|
# cancel_research_job()'s own behavior is covered separately below.
|
|
def _fake_analysis(tenant_id, comp, ctx):
|
|
if comp['name'] == 'Exodus':
|
|
scrape_repository.update(run['id'], {'cancel_requested': True})
|
|
return {'path': 'fast', 'result': {}}
|
|
|
|
monkeypatch.setattr('services.analysis_service.run_competitor_analysis', _fake_analysis)
|
|
monkeypatch.setattr('services.alert_service.send_gotify', lambda **kw: None)
|
|
|
|
jobs.run_research_job(run['id'], {
|
|
'tenant_id': tenant['id'], 'competitors': [{'id': first['id']}, {'id': second['id']}]
|
|
})
|
|
|
|
updated = scrape_repository.get_by_id(run['id'])
|
|
assert updated['status'] == 'cancelled'
|
|
assert len(updated['results']['success']) == 1 # Exodus finished before cancellation landed
|
|
assert updated['results']['success'][0]['name'] == 'Exodus'
|
|
|
|
|
|
def test_cancel_research_job_returns_false_for_unknown_job_id():
|
|
assert jobs.cancel_research_job('does-not-exist') is False
|
|
|
|
|
|
def test_cancel_research_job_cancels_still_queued_job(monkeypatch):
|
|
tenant, competitor, run = _setup()
|
|
|
|
class _FakeJob:
|
|
cancelled = False
|
|
|
|
def get_status(self):
|
|
return 'queued'
|
|
|
|
def cancel(self):
|
|
self.cancelled = True
|
|
|
|
fake_job = _FakeJob()
|
|
monkeypatch.setattr(jobs.Job, 'fetch', lambda job_id, connection=None: fake_job)
|
|
|
|
result = jobs.cancel_research_job(run['id'])
|
|
|
|
assert result is True
|
|
assert fake_job.cancelled is True
|
|
updated = scrape_repository.get_by_id(run['id'])
|
|
assert updated['status'] == 'cancelled' # will never run at all — safe to set immediately
|
|
assert updated['cancel_requested'] is True
|
|
|
|
|
|
def test_cancel_research_job_sets_flag_when_already_running(monkeypatch):
|
|
tenant, competitor, run = _setup()
|
|
scrape_repository.update(run['id'], {'status': 'running'})
|
|
|
|
class _FakeJob:
|
|
def get_status(self):
|
|
return 'started' # already executing — RQ can't interrupt this
|
|
|
|
def cancel(self):
|
|
raise AssertionError('must not attempt to cancel a job already executing')
|
|
|
|
monkeypatch.setattr(jobs.Job, 'fetch', lambda job_id, connection=None: _FakeJob())
|
|
|
|
result = jobs.cancel_research_job(run['id'])
|
|
|
|
assert result is True
|
|
updated = scrape_repository.get_by_id(run['id'])
|
|
assert updated['cancel_requested'] is True
|
|
assert updated['status'] == 'running' # unchanged — the running job itself flips this at its next checkpoint
|
|
|
|
|
|
def test_cancel_research_job_sets_flag_when_rq_has_no_record(monkeypatch):
|
|
"""Job already finished (or was never queued) by the time cancel is requested — still a safe no-op."""
|
|
tenant, competitor, run = _setup()
|
|
|
|
def _raise_no_such_job(job_id, connection=None):
|
|
raise jobs.NoSuchJobError()
|
|
|
|
monkeypatch.setattr(jobs.Job, 'fetch', _raise_no_such_job)
|
|
|
|
result = jobs.cancel_research_job(run['id'])
|
|
|
|
assert result is True
|
|
updated = scrape_repository.get_by_id(run['id'])
|
|
assert updated['cancel_requested'] is True
|
|
|
|
|
|
def test_enqueue_research_job_returns_job_id(monkeypatch):
|
|
captured = {}
|
|
monkeypatch.setattr(
|
|
jobs.normal_q, 'enqueue',
|
|
lambda fn, job_id, data, job_id_kw=None, **kw: captured.update(job_id=job_id, data=data)
|
|
)
|
|
# rq's Queue.enqueue signature takes job_id as a kwarg named "job_id";
|
|
# patch it generically to capture call args regardless of exact form.
|
|
monkeypatch.setattr(jobs.normal_q, 'enqueue', lambda *a, **k: captured.update(args=a, kwargs=k))
|
|
|
|
result = jobs.enqueue_research_job('job123', {'tenant_id': 't1'})
|
|
|
|
assert result == 'job123'
|
|
assert captured['kwargs']['job_id'] == 'job123'
|