Initial commit
This commit is contained in:
commit
fe5ef9132a
15 changed files with 888 additions and 0 deletions
169
tests/test_runner.py
Normal file
169
tests/test_runner.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
"""What the runner has to guarantee: every exit leaves the source schedulable.
|
||||
|
||||
The bugs this suite exists for are all the same shape — a poll that ends without
|
||||
re-arming next_poll_at, so the dispatcher either hammers the source every minute
|
||||
or drops it forever.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.utils import timezone
|
||||
|
||||
from yksa_kit.choices import RunStatus
|
||||
from yksa_poller.adapters import AdapterRegistry, BaseAdapter
|
||||
from yksa_poller.runner import Poller, cleanup_runs, due_source_ids, run_source
|
||||
|
||||
from tests.testapp.models import Kind, Run, Satellite, Source
|
||||
|
||||
ADAPTERS = AdapterRegistry()
|
||||
|
||||
|
||||
@ADAPTERS.register(Kind.HTTP)
|
||||
class TwoRecords(BaseAdapter):
|
||||
def fetch(self):
|
||||
yield {"id": 1}
|
||||
yield {"id": 2}
|
||||
|
||||
|
||||
class Exploding(BaseAdapter):
|
||||
def fetch(self):
|
||||
raise RuntimeError("upstream on fire")
|
||||
yield # pragma: no cover
|
||||
|
||||
|
||||
class Unconfigured(BaseAdapter):
|
||||
def fetch(self):
|
||||
raise NotImplementedError("no credentials for this source")
|
||||
yield # pragma: no cover
|
||||
|
||||
|
||||
def poller(adapters=None, **kwargs):
|
||||
return Poller(
|
||||
source_model=Source,
|
||||
run_model=Run,
|
||||
adapters=adapters if adapters is not None else ADAPTERS,
|
||||
persist=kwargs.pop("persist", lambda src, rec: True),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def source(db):
|
||||
return Source.objects.create(name="Upstream", slug="upstream", kind=Kind.HTTP)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_a_successful_poll_counts_and_reschedules(source):
|
||||
before = source.next_poll_at
|
||||
result = run_source(poller(), source.pk)
|
||||
|
||||
assert result["fetched"] == 2
|
||||
assert result["new"] == 2
|
||||
source.refresh_from_db()
|
||||
assert source.last_status == RunStatus.SUCCESS
|
||||
assert source.success_count == 1
|
||||
assert source.next_poll_at > before
|
||||
assert source.runs.get().status == RunStatus.SUCCESS
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_only_new_records_are_counted_as_new(source):
|
||||
result = run_source(poller(persist=lambda src, rec: rec["id"] == 1), source.pk)
|
||||
assert (result["fetched"], result["new"]) == (2, 1)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_a_failing_poll_still_reschedules_and_reraises(source):
|
||||
before = source.next_poll_at
|
||||
with pytest.raises(RuntimeError):
|
||||
run_source(poller(adapters={Kind.HTTP: Exploding}), source.pk)
|
||||
|
||||
source.refresh_from_db()
|
||||
assert source.last_status == RunStatus.FAILED
|
||||
assert source.failure_count == 1
|
||||
assert source.next_poll_at > before
|
||||
assert "upstream on fire" in source.runs.get().error
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_an_unconfigured_adapter_is_skipped_not_failed(source):
|
||||
"""A source nobody finished configuring must not look like an outage."""
|
||||
result = run_source(poller(adapters={Kind.HTTP: Unconfigured}), source.pk)
|
||||
|
||||
assert result["status"] == RunStatus.SKIPPED
|
||||
source.refresh_from_db()
|
||||
assert source.failure_count == 0
|
||||
assert source.runs.get().status == RunStatus.SKIPPED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_a_kind_with_no_adapter_is_skipped(source):
|
||||
source.kind = Kind.PUSH
|
||||
source.save()
|
||||
result = run_source(poller(), source.pk)
|
||||
assert result["status"] == RunStatus.SKIPPED
|
||||
assert "no adapter" in source.runs.get().error
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_a_disabled_source_does_nothing_at_all(source):
|
||||
source.is_enabled = False
|
||||
source.save()
|
||||
assert run_source(poller(), source.pk)["skipped"] is True
|
||||
assert source.runs.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_track_carries_extra_fields_onto_the_source(source):
|
||||
"""tdas uses this for last_rx_at, the lower bound of its next query."""
|
||||
stamp = timezone.now()
|
||||
|
||||
def track(src, record, extra):
|
||||
extra["last_run_at"] = stamp
|
||||
|
||||
run_source(poller(track=track), source.pk)
|
||||
source.refresh_from_db()
|
||||
assert source.last_run_at == stamp
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_dispatch_picks_only_due_enabled_pollable_sources():
|
||||
past = timezone.now() - timedelta(minutes=1)
|
||||
future = timezone.now() + timedelta(hours=1)
|
||||
due = Source.objects.create(name="due", slug="due", kind=Kind.HTTP, next_poll_at=past)
|
||||
Source.objects.create(name="later", slug="later", kind=Kind.HTTP, next_poll_at=future)
|
||||
Source.objects.create(name="off", slug="off", kind=Kind.HTTP, next_poll_at=past, is_enabled=False)
|
||||
Source.objects.create(name="pushed", slug="pushed", kind=Kind.PUSH, next_poll_at=past)
|
||||
|
||||
ids = due_source_ids(poller(unpollable_kinds=(Kind.PUSH,)))
|
||||
assert ids == [due.pk]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cleanup_deletes_only_old_runs(source):
|
||||
fresh = Run.objects.create(source=source, status=RunStatus.SUCCESS)
|
||||
old = Run.objects.create(source=source, status=RunStatus.SUCCESS)
|
||||
# started_at is auto_now_add, so age has to be forced.
|
||||
Run.objects.filter(pk=old.pk).update(
|
||||
started_at=timezone.now() - timedelta(days=40)
|
||||
)
|
||||
|
||||
assert cleanup_runs(Run, retention_days=30)["deleted"] == 1
|
||||
assert list(Run.objects.values_list("pk", flat=True)) == [fresh.pk]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_subscription_ids_skip_satellites_missing_the_identifier(source):
|
||||
"""An upstream query built from a blank identifier returns either nothing or
|
||||
everything, and both are wrong."""
|
||||
with_norad = Satellite.objects.create(norad_cat_id="25544", internal_id="iss")
|
||||
Satellite.objects.create(norad_cat_id="", internal_id="unlaunched")
|
||||
source.tracked_satellites.set(Satellite.objects.all())
|
||||
|
||||
assert source.subscription_id_values() == [with_norad.norad_cat_id]
|
||||
|
||||
source.subscription_id_kind = "internal"
|
||||
assert sorted(source.subscription_id_values()) == ["iss", "unlaunched"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue