Skip to content

Commit 1462e74

Browse files
snopokeclaude
andcommitted
Add integration tests for context provider wiring
Covers the Celery, Procrastinate, @track, and Task.error() entry points with a real provider, not just the low-level helpers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent e0fcaa0 commit 1462e74

4 files changed

Lines changed: 226 additions & 1 deletion

File tree

tests/test_celery_error.py

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,16 @@
33
import pytest
44

55
from taskbadger import StatusEnum
6-
from taskbadger.celery import Task
6+
from taskbadger._error_context import capture_error_data
7+
from taskbadger.celery import (
8+
TB_ERROR_CTX_TOKEN,
9+
TB_TASK_ID,
10+
Task,
11+
task_failure_handler,
12+
task_postrun_handler,
13+
task_prerun_handler,
14+
)
15+
from taskbadger.context_providers import ContextProvider
716
from taskbadger.mug import Badger
817
from tests.utils import task_for_test
918

@@ -41,3 +50,103 @@ def add_error(self, a, b):
4150
data_kwarg = update.call_args_list[1][1]["data"]
4251
assert "Traceback" in data_kwarg["exception"]
4352
assert Badger.current.session().client is None
53+
54+
55+
class _FakeRequest(dict):
56+
"""Minimal stand-in for Celery's request `Context`: dict-like plus a
57+
`.headers` attribute, which is all `_get_taskbadger_task_id` needs."""
58+
59+
headers = None
60+
61+
62+
class _FakeEinfo:
63+
"""Minimal stand-in for Celery's `ExceptionInfo`: wraps the exception and
64+
renders a traceback-shaped string, mirroring what `task_failure`/`task_retry`
65+
signals actually pass to `_update_task`."""
66+
67+
def __init__(self, exc):
68+
self.exception = exc
69+
70+
def __str__(self):
71+
return f"Traceback (most recent call last):\n{self.exception!r}"
72+
73+
74+
@pytest.mark.usefixtures("_bind_settings")
75+
def test_signal_handlers_wire_context_provider_snapshot_to_failure():
76+
"""`task_prerun_handler` snapshots providers and stashes the token on the
77+
request; `task_failure_handler` should see that exact snapshot when
78+
building error data; `task_postrun_handler` then resets it."""
79+
80+
seen_snapshots = []
81+
82+
class TrackingProvider(ContextProvider):
83+
identifier = "tracking"
84+
85+
def snapshot(self):
86+
return "baseline"
87+
88+
def capture_error_context(self, exception, snapshot=None):
89+
seen_snapshots.append(snapshot)
90+
return {"snapshot": snapshot}
91+
92+
Badger.current.settings.context_providers = [TrackingProvider()]
93+
94+
sender = mock.Mock()
95+
sender.request = _FakeRequest({TB_TASK_ID: "tb-1"})
96+
task = task_for_test(id="tb-1", status=StatusEnum.PROCESSING)
97+
sender.taskbadger_task = task
98+
99+
with (
100+
mock.patch("taskbadger.celery.safe_get_task", return_value=task),
101+
mock.patch("taskbadger.celery.update_task_safe", return_value=task) as update,
102+
mock.patch("taskbadger.celery.enter_session"),
103+
mock.patch("taskbadger.celery.exit_session"),
104+
):
105+
task_prerun_handler(sender=sender)
106+
assert TB_ERROR_CTX_TOKEN in sender.request
107+
108+
task_failure_handler(sender=sender, einfo=_FakeEinfo(ValueError("boom")))
109+
task_postrun_handler(sender=sender)
110+
111+
# The snapshot seen at failure time is the one taken at prerun, not a
112+
# missing/None one -- proving the token round-trips through the request.
113+
assert seen_snapshots == ["baseline"]
114+
data_kwarg = update.call_args.kwargs["data"]
115+
assert data_kwarg["tracking"] == {"snapshot": "baseline"}
116+
117+
118+
@pytest.mark.usefixtures("_bind_settings")
119+
def test_postrun_resets_context_after_error():
120+
"""Once `task_postrun_handler` runs, a later error in the same thread with
121+
no provider snapshot taken shouldn't see a stale one left over from the
122+
previous task."""
123+
124+
class TrackingProvider(ContextProvider):
125+
identifier = "tracking"
126+
127+
def snapshot(self):
128+
return "baseline"
129+
130+
def capture_error_context(self, exception, snapshot=None):
131+
return {"snapshot": snapshot}
132+
133+
Badger.current.settings.context_providers = [TrackingProvider()]
134+
135+
task = task_for_test(id="tb-2", status=StatusEnum.PROCESSING)
136+
sender = mock.Mock()
137+
sender.request = _FakeRequest({TB_TASK_ID: "tb-2"})
138+
sender.taskbadger_task = task
139+
140+
with (
141+
mock.patch("taskbadger.celery.safe_get_task", return_value=task),
142+
mock.patch("taskbadger.celery.update_task_safe", return_value=task),
143+
mock.patch("taskbadger.celery.enter_session"),
144+
mock.patch("taskbadger.celery.exit_session"),
145+
):
146+
task_prerun_handler(sender=sender)
147+
task_postrun_handler(sender=sender)
148+
149+
# A failure reported outside of any tracked task's prerun/postrun window
150+
# (e.g. directly via Task.error) has no snapshot to compare against.
151+
data = capture_error_data(ValueError("boom"))
152+
assert data["tracking"] == {"snapshot": None}

tests/test_decorators.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from unittest import mock
22

33
from taskbadger import track
4+
from taskbadger.context_providers import ContextProvider
45
from taskbadger.mug import Badger, Settings
56

67

@@ -52,6 +53,32 @@ def test(arg):
5253
assert update.call_args.kwargs["data"]["exception"] == "test"
5354

5455

56+
@mock.patch("taskbadger.decorators.create_task_safe")
57+
@mock.patch("taskbadger.decorators._update_safe")
58+
def test_track_decorator_error_consults_context_provider(update, create):
59+
class FakeProvider(ContextProvider):
60+
identifier = "fake"
61+
62+
def capture_error_context(self, exception, snapshot=None):
63+
return {"detail": str(exception)}
64+
65+
Badger.current.bind(Settings("https://taskbadger.net", "token", "org", "proj", context_providers=[FakeProvider()]))
66+
try:
67+
68+
@track
69+
def test(arg):
70+
raise Exception("test")
71+
72+
try:
73+
test("test")
74+
except Exception:
75+
pass
76+
finally:
77+
Badger.current.bind(None)
78+
79+
assert update.call_args.kwargs["data"] == {"exception": "test", "fake": {"detail": "test"}}
80+
81+
5582
@mock.patch("taskbadger.decorators._update_safe")
5683
def test_track_decorator_badger_not_configured(update):
5784
@track

tests/test_procrastinate.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from procrastinate import testing
88

99
from taskbadger import StatusEnum
10+
from taskbadger.context_providers import ContextProvider
11+
from taskbadger.mug import Badger
1012
from taskbadger.procrastinate import TB_TASK_ID_KWARG, _instrument_task, current_task, track
1113
from tests.utils import task_for_test
1214

@@ -91,6 +93,35 @@ def boom():
9193
assert err_call.kwargs["data"] == {"x": 1, "exception": "nope"}
9294

9395

96+
@pytest.mark.usefixtures("_bind_settings")
97+
def test_worker_marks_error_consults_context_provider(app):
98+
class FakeProvider(ContextProvider):
99+
identifier = "fake"
100+
101+
def capture_error_context(self, exception, snapshot=None):
102+
return {"detail": str(exception)}
103+
104+
Badger.current.settings.context_providers = [FakeProvider()]
105+
106+
@app.task(name="boom_with_provider")
107+
def boom():
108+
raise ValueError("nope")
109+
110+
_instrument_task(boom, system=None, manual=True)
111+
112+
with (
113+
mock.patch("taskbadger.procrastinate.update_task_safe") as update,
114+
mock.patch("taskbadger.sdk.get_task") as get,
115+
):
116+
get.return_value = task_for_test(status=StatusEnum.PROCESSING)
117+
update.return_value = task_for_test(status=StatusEnum.PROCESSING)
118+
with pytest.raises(ValueError, match="nope"):
119+
boom.func(**{TB_TASK_ID_KWARG: "tb-provider"})
120+
121+
err_call = update.call_args_list[-1]
122+
assert err_call.kwargs["data"] == {"exception": "nope", "fake": {"detail": "nope"}}
123+
124+
94125
@pytest.mark.usefixtures("_bind_settings")
95126
def test_worker_no_id_runs_clean(app):
96127
@app.task(name="add2")

tests/test_sdk.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import pytest
77

88
from taskbadger import Action, EmailIntegration, StatusEnum, WebhookIntegration, create_task
9+
from taskbadger.context_providers import ContextProvider
910
from taskbadger.exceptions import TaskbadgerException
1011
from taskbadger.internal.models import (
1112
PatchedTaskRequest,
@@ -207,6 +208,63 @@ def test_update_data(settings, patched_update):
207208
_verify_update(settings, patched_update, data={"a": 1})
208209

209210

211+
def test_error_with_exception(settings, patched_update):
212+
api_task = task_for_test()
213+
task = Task(api_task)
214+
215+
patched_update.return_value = Response(HTTPStatus.OK, b"", {}, api_task)
216+
task.error(exception=ValueError("boom"))
217+
218+
_verify_update(settings, patched_update, status=StatusEnum.ERROR, data={"exception": "boom"})
219+
220+
221+
def test_error_with_exception_consults_context_provider(settings, patched_update):
222+
class FakeProvider(ContextProvider):
223+
identifier = "fake"
224+
225+
def capture_error_context(self, exception, snapshot=None):
226+
return {"detail": str(exception)}
227+
228+
settings.context_providers = [FakeProvider()]
229+
230+
api_task = task_for_test()
231+
task = Task(api_task)
232+
patched_update.return_value = Response(HTTPStatus.OK, b"", {}, api_task)
233+
task.error(exception=ValueError("boom"))
234+
235+
_verify_update(
236+
settings,
237+
patched_update,
238+
status=StatusEnum.ERROR,
239+
data={"exception": "boom", "fake": {"detail": "boom"}},
240+
)
241+
242+
243+
def test_error_explicit_data_overrides_provider_data(settings, patched_update):
244+
"""Explicit `data` passed to `error()` wins over provider-derived data for
245+
overlapping keys."""
246+
247+
class FakeProvider(ContextProvider):
248+
identifier = "fake"
249+
250+
def capture_error_context(self, exception, snapshot=None):
251+
return {"detail": "from provider"}
252+
253+
settings.context_providers = [FakeProvider()]
254+
255+
api_task = task_for_test()
256+
task = Task(api_task)
257+
patched_update.return_value = Response(HTTPStatus.OK, b"", {}, api_task)
258+
task.error(exception=ValueError("boom"), data={"exception": "overridden", "extra": 1})
259+
260+
_verify_update(
261+
settings,
262+
patched_update,
263+
status=StatusEnum.ERROR,
264+
data={"exception": "overridden", "extra": 1, "fake": {"detail": "from provider"}},
265+
)
266+
267+
210268
def test_increment_value(settings, patched_update):
211269
api_task = task_for_test()
212270
task = Task(api_task)

0 commit comments

Comments
 (0)