"""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"]