django-tasks-pubsub 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-tasks-pubsub
3
+ Version: 0.1.1
4
+ Summary: Django tasks backend for Google Cloud Pub/Sub
5
+ Requires-Python: >=3.13
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: django>=6.0.5
8
+ Requires-Dist: google-cloud-pubsub>=2.38.0
9
+
10
+ hello, world.
@@ -0,0 +1 @@
1
+ hello, world.
@@ -0,0 +1,7 @@
1
+ from .backend import PubSubBackend
2
+ from .pubsub_task_decorator import pubsub_task
3
+
4
+ __all__ = [
5
+ "PubSubBackend",
6
+ "pubsub_task",
7
+ ]
@@ -0,0 +1,110 @@
1
+ import dataclasses
2
+ import json
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from typing import Optional
6
+
7
+ from django.conf import settings
8
+ from django.tasks import Task
9
+ from django.tasks.backends.base import BaseTaskBackend
10
+ from django.tasks.base import TaskResult as BaseTaskResult, TaskResultStatus
11
+ from django.utils import timezone
12
+ from django.utils.crypto import get_random_string
13
+
14
+ from django_tasks_pubsub.publisher import get_publisher
15
+ from django_tasks_pubsub.publisher import publish
16
+ from django_tasks_pubsub.pubsub_task_decorator import PubSubMetaData
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class TaskPayload:
21
+ backend: str
22
+ module_path: str
23
+ name: str
24
+ priority: int
25
+ queue_name: str
26
+ takes_context: bool
27
+ run_after: Optional[datetime] = None
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Payload:
32
+ task: TaskPayload
33
+ task_id: str
34
+ enqueued_at: str
35
+ args: list
36
+ kwargs: dict
37
+
38
+
39
+ class TaskResult(BaseTaskResult):
40
+ pass
41
+
42
+
43
+ class PubSubBackend(BaseTaskBackend):
44
+ @staticmethod
45
+ def get_pubsub_metadata(task: Task) -> PubSubMetaData:
46
+ task_func = getattr(task, "func")
47
+
48
+ return getattr(task_func, "__pubsub_metadata", PubSubMetaData())
49
+
50
+ def get_topic_id(self, task: Task):
51
+ return (
52
+ self.get_pubsub_metadata(task=task).topic_id
53
+ or settings.PUBSUB_DEFAULT_TOPIC_ID
54
+ )
55
+
56
+ def get_topic_path(self, task: Task):
57
+ topic_id = self.get_topic_id(task)
58
+
59
+ publisher = get_publisher()
60
+
61
+ topic_path = publisher.topic_path(
62
+ settings.PUBSUB_PROJECT_ID,
63
+ topic_id,
64
+ )
65
+
66
+ return topic_path
67
+
68
+ def enqueue(self, task: Task, args, kwargs):
69
+ enqueued_at = timezone.now()
70
+ task_id = get_random_string(32)
71
+
72
+ payload: Payload = Payload(
73
+ task=TaskPayload(
74
+ backend=task.backend,
75
+ module_path=task.module_path,
76
+ name=task.name,
77
+ priority=task.priority,
78
+ queue_name=task.queue_name,
79
+ takes_context=task.takes_context,
80
+ ),
81
+ task_id=task_id,
82
+ enqueued_at=enqueued_at.isoformat(),
83
+ args=args,
84
+ kwargs=kwargs,
85
+ )
86
+ # Data to send to the queue.
87
+ data = json.dumps(
88
+ dataclasses.asdict(payload),
89
+ ).encode("utf-8")
90
+
91
+ topic_path = self.get_topic_path(
92
+ task,
93
+ )
94
+
95
+ publish(topic=topic_path, data=data)
96
+
97
+ return TaskResult(
98
+ id=task_id,
99
+ task=task,
100
+ enqueued_at=enqueued_at,
101
+ started_at=None,
102
+ finished_at=None,
103
+ status=TaskResultStatus.READY,
104
+ args=args,
105
+ kwargs=kwargs,
106
+ last_attempted_at=None,
107
+ backend=task.backend,
108
+ errors=[],
109
+ worker_ids=[],
110
+ )
@@ -0,0 +1,54 @@
1
+ import importlib
2
+ from datetime import datetime
3
+
4
+ from django.tasks import TaskContext
5
+ from django.tasks.base import TaskResultStatus
6
+
7
+ from django_tasks_pubsub.backend import Payload, TaskResult
8
+
9
+
10
+ def dispatch(payload: Payload):
11
+ module_path = payload.task.module_path
12
+ task_name = payload.task.name
13
+
14
+ try:
15
+ module = importlib.import_module(module_path)
16
+ except ModuleNotFoundError:
17
+ module_path, task_name_from_path = module_path.rsplit(".", 1)
18
+ module = importlib.import_module(module_path)
19
+ task_name = task_name or task_name_from_path
20
+
21
+ # This is the decorated task function, decoration makes it a Task object
22
+ task = getattr(module, task_name)
23
+
24
+ # Actual task function that we can call
25
+ task_function = getattr(task, "func", task)
26
+
27
+ if payload.task.takes_context:
28
+ context = TaskContext(
29
+ task_result=TaskResult(
30
+ id=payload.task_id,
31
+ task=task,
32
+ enqueued_at=datetime.fromisoformat(payload.enqueued_at),
33
+ started_at=None,
34
+ finished_at=None,
35
+ status=TaskResultStatus.READY,
36
+ args=payload.args,
37
+ kwargs=payload.kwargs,
38
+ last_attempted_at=None,
39
+ backend=payload.task.backend,
40
+ errors=[],
41
+ worker_ids=[],
42
+ ),
43
+ )
44
+ task_function(
45
+ context,
46
+ *payload.args,
47
+ **payload.kwargs,
48
+ )
49
+ return
50
+
51
+ task_function(
52
+ *payload.args,
53
+ **payload.kwargs,
54
+ )
@@ -0,0 +1,14 @@
1
+ from google.cloud import pubsub_v1
2
+
3
+ _publisher: pubsub_v1.PublisherClient | None = None
4
+
5
+
6
+ def get_publisher():
7
+ global _publisher
8
+ if _publisher is None:
9
+ _publisher = pubsub_v1.PublisherClient()
10
+ return _publisher
11
+
12
+
13
+ def publish(topic: str, data: bytes):
14
+ get_publisher().publish(topic=topic, data=data)
@@ -0,0 +1,19 @@
1
+ from collections.abc import Callable
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass(frozen=True, slots=True, kw_only=True)
6
+ class PubSubMetaData:
7
+ topic_id: str | None = None
8
+
9
+
10
+ def pubsub_task(topic_id: str | None = None):
11
+ def wrapper(func: Callable):
12
+ setattr(
13
+ func,
14
+ "__pubsub_metadata",
15
+ PubSubMetaData(topic_id=topic_id),
16
+ )
17
+ return func
18
+
19
+ return wrapper
@@ -0,0 +1,71 @@
1
+ import base64
2
+ import json
3
+ import logging
4
+
5
+ from django.http import HttpResponse
6
+ from django.utils.decorators import method_decorator
7
+ from django.views import View
8
+ from django.views.decorators.csrf import csrf_exempt
9
+
10
+ from django_tasks_pubsub.dispatcher import dispatch
11
+ from django_tasks_pubsub.backend import Payload, TaskPayload
12
+
13
+
14
+ @method_decorator(csrf_exempt, name="dispatch")
15
+ class PubSubPushView(View):
16
+ """
17
+ Receives Pub/Sub push messages and dispatches them to TASK_DISPATCH_FUNCTION.
18
+
19
+ Pub/Sub push format:
20
+ {
21
+ "message": {
22
+ "data": "<base64-encoded JSON payload>",
23
+ "messageId": "...",
24
+ "publishTime": "..."
25
+ },
26
+ "subscription": "projects/.../subscriptions/..."
27
+ }
28
+
29
+ Returns 204 on success (Pub/Sub ACKs on any 2xx).
30
+ Returns 500 on task failure (Pub/Sub will retry).
31
+ Returns 400 on malformed message (no retry).
32
+ """
33
+
34
+ def post(self, request):
35
+ try:
36
+ envelope = json.loads(request.body)
37
+ except (json.JSONDecodeError, ValueError):
38
+ logging.error("PubSubPushView: invalid JSON body")
39
+ return HttpResponse(status=400)
40
+
41
+ encoded_data = envelope.get("message", {}).get("data", "")
42
+ if not encoded_data:
43
+ logging.error("PubSubPushView: missing message.data")
44
+ return HttpResponse(status=400)
45
+
46
+ try:
47
+ payload_dict = json.loads(base64.b64decode(encoded_data))
48
+ except Exception as exc:
49
+ logging.error(f"PubSubPushView: failed to decode message: {exc}")
50
+ return HttpResponse(status=400)
51
+
52
+ try:
53
+ payload = Payload(
54
+ task=TaskPayload(
55
+ **payload_dict.pop("task"),
56
+ ),
57
+ **payload_dict,
58
+ )
59
+ except Exception as exc:
60
+ logging.exception(f"PubSubPushView: failed to create payload: {exc}")
61
+ return HttpResponse(status=400)
62
+
63
+ logging.info(f"PubSubPushView: dispatching task_type={payload.task.name!r}")
64
+
65
+ try:
66
+ dispatch(payload=payload)
67
+ except BaseException as exc:
68
+ logging.exception(f"PubSubPushView: task failed: {payload!r} error={exc}")
69
+ return HttpResponse(status=500)
70
+
71
+ return HttpResponse(status=204)
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-tasks-pubsub
3
+ Version: 0.1.1
4
+ Summary: Django tasks backend for Google Cloud Pub/Sub
5
+ Requires-Python: >=3.13
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: django>=6.0.5
8
+ Requires-Dist: google-cloud-pubsub>=2.38.0
9
+
10
+ hello, world.
@@ -0,0 +1,16 @@
1
+ README.md
2
+ pyproject.toml
3
+ django_tasks_pubsub/__init__.py
4
+ django_tasks_pubsub/backend.py
5
+ django_tasks_pubsub/dispatcher.py
6
+ django_tasks_pubsub/publisher.py
7
+ django_tasks_pubsub/pubsub_task_decorator.py
8
+ django_tasks_pubsub/views.py
9
+ django_tasks_pubsub.egg-info/PKG-INFO
10
+ django_tasks_pubsub.egg-info/SOURCES.txt
11
+ django_tasks_pubsub.egg-info/dependency_links.txt
12
+ django_tasks_pubsub.egg-info/requires.txt
13
+ django_tasks_pubsub.egg-info/top_level.txt
14
+ tests/test_dispatching_with_pubsub_task_backend.py
15
+ tests/test_enqueing_with_pubsub_task_backend.py
16
+ tests/test_pubsub_view.py
@@ -0,0 +1,2 @@
1
+ django>=6.0.5
2
+ google-cloud-pubsub>=2.38.0
@@ -0,0 +1 @@
1
+ django_tasks_pubsub
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "django-tasks-pubsub"
3
+ version = "v0.1.1"
4
+ description = "Django tasks backend for Google Cloud Pub/Sub"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ "django>=6.0.5",
9
+ "google-cloud-pubsub>=2.38.0",
10
+ ]
11
+
12
+ [dependency-groups]
13
+ dev = [
14
+ "build>=1.5.0",
15
+ "coverage>=7.14.0",
16
+ "mypy>=2.1.0",
17
+ "ruff>=0.15.13",
18
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,119 @@
1
+ from unittest.mock import patch, Mock
2
+
3
+ from django.tasks import task, TaskContext, Task
4
+ from django.test import SimpleTestCase
5
+ from django.test.utils import override_settings
6
+
7
+ from django_tasks_pubsub.dispatcher import dispatch
8
+ from django_tasks_pubsub.backend import Payload, TaskPayload
9
+
10
+ sample_task_with_context_mock = Mock()
11
+
12
+
13
+ @task(takes_context=True)
14
+ def sample_task_with_context(context: TaskContext, specified_arg, *args, **kwargs):
15
+ sample_task_with_context_mock(
16
+ context,
17
+ specified_arg,
18
+ *args,
19
+ **kwargs,
20
+ )
21
+
22
+
23
+ @override_settings(PUBSUB_PROJECT_ID="project_name")
24
+ @override_settings(PUBSUB_DEFAULT_TOPIC_ID="default_topic_id")
25
+ @override_settings(TASKS={"default": {"BACKEND": "django_tasks_pubsub.PubSubBackend"}})
26
+ class TestPubSubBackendEnqueuing(SimpleTestCase):
27
+ def setUp(self):
28
+ self.get_topic_path_patcher = patch(
29
+ "django_tasks_pubsub.google_cloud_pubsub_backend.GoogleCloudPubSubBackend.get_topic_path",
30
+ return_value="expected/topic/path",
31
+ )
32
+ self.get_topic_path_mock = self.get_topic_path_patcher.start()
33
+
34
+ def tearDown(self):
35
+ self.get_topic_path_patcher.stop()
36
+
37
+
38
+ class TestPubSubTaskDispatching(SimpleTestCase):
39
+ def setUp(self):
40
+ sample_task_with_context_mock.reset_mock()
41
+
42
+ def test_dispatching_task_with_context_passes_expected_context_to_task_function(
43
+ self,
44
+ ):
45
+ payload = Payload(
46
+ task=TaskPayload(
47
+ backend="default",
48
+ module_path="tests.test_dispatching_with_pubsub_task_backend",
49
+ name="sample_task_with_context",
50
+ priority=0,
51
+ queue_name="default",
52
+ takes_context=True,
53
+ ),
54
+ task_id="test-task-id",
55
+ enqueued_at="2026-05-12T10:00:00+00:00",
56
+ args=[
57
+ "spec_arg",
58
+ "arg1",
59
+ "arg2",
60
+ ],
61
+ kwargs={
62
+ "kwarg1": "val1",
63
+ "kwarg2": "val2",
64
+ },
65
+ )
66
+
67
+ dispatch(payload)
68
+
69
+ sample_task_with_context_mock.assert_called_once()
70
+
71
+ context: TaskContext = sample_task_with_context_mock.call_args.args[0]
72
+
73
+ self.assertIsNotNone(context)
74
+
75
+ with self.subTest("context contains the task result id"):
76
+ self.assertEqual(context.task_result.id, "test-task-id")
77
+
78
+ with self.subTest("task receives expected args and kwargs after context"):
79
+ sample_task_with_context_mock.assert_called_once_with(
80
+ context,
81
+ "spec_arg",
82
+ "arg1",
83
+ "arg2",
84
+ kwarg1="val1",
85
+ kwarg2="val2",
86
+ )
87
+
88
+ # SubTest
89
+ with self.subTest("context should be TaskContext"):
90
+ self.assertIsInstance(context, TaskContext)
91
+
92
+ called_task_result_task: Task = context.task_result.task
93
+
94
+ self.assertIsNotNone(
95
+ called_task_result_task,
96
+ )
97
+
98
+ self.assertIsInstance(
99
+ called_task_result_task,
100
+ Task,
101
+ )
102
+
103
+ # SubTest should have ALL the expected properties set
104
+ with self.subTest(
105
+ "should have ALL the expected properties set at the task oject"
106
+ ):
107
+ self.assertEqual(called_task_result_task.name, "sample_task_with_context")
108
+ self.assertEqual(called_task_result_task.backend, "default")
109
+ self.assertEqual(
110
+ called_task_result_task.module_path,
111
+ "tests.test_dispatching_with_pubsub_task_backend.sample_task_with_context",
112
+ )
113
+ self.assertEqual(called_task_result_task.priority, 0)
114
+ self.assertEqual(called_task_result_task.queue_name, "default")
115
+ self.assertEqual(called_task_result_task.takes_context, True)
116
+ self.assertEqual(
117
+ called_task_result_task.func,
118
+ sample_task_with_context.func,
119
+ )
@@ -0,0 +1,211 @@
1
+ import json
2
+ from datetime import datetime
3
+ from unittest.mock import patch, Mock
4
+
5
+ from django.tasks import task
6
+ from django.test import SimpleTestCase
7
+ from django.test.utils import override_settings
8
+
9
+ from django_tasks_pubsub import pubsub_task
10
+
11
+
12
+ @task
13
+ def sample_task(specified_arg, *args, **kwargs):
14
+ pass
15
+
16
+
17
+ @task(takes_context=True)
18
+ def sample_task_with_context(context, specified_arg, *args, **kwargs):
19
+ pass
20
+
21
+
22
+ @task
23
+ @pubsub_task("test-topic-name")
24
+ def sample_task_with_pubsub_topic(specified_arg, *args, **kwargs):
25
+ pass
26
+
27
+
28
+ sample_task_with_pubsub_topic_watchdog = Mock()
29
+
30
+
31
+ @task
32
+ @pubsub_task("test-topic-name")
33
+ def sample_task_with_pubsub_topic_with_watchdog(*args, **kwargs):
34
+ sample_task_with_pubsub_topic_watchdog(
35
+ *args,
36
+ **kwargs,
37
+ )
38
+
39
+
40
+ @override_settings(PUBSUB_PROJECT_ID="project_name")
41
+ @override_settings(PUBSUB_DEFAULT_TOPIC_ID="default_topic_id")
42
+ @override_settings(TASKS={"default": {"BACKEND": "django_tasks_pubsub.PubSubBackend"}})
43
+ class TestPubsubTopicGeneration(SimpleTestCase):
44
+ @override_settings(PUBSUB_DEFAULT_TOPIC_ID="other_default_topic_id")
45
+ @patch("django_tasks_pubsub.backend.get_publisher")
46
+ @patch("django_tasks_pubsub.backend.publish", return_value="")
47
+ def test_enqueuing_a_task_with_out_topic_defined_should_use_default(
48
+ self, mocked_publish_method, mocked_get_publisher
49
+ ):
50
+ class DummyPublisher:
51
+ def __init__(self):
52
+ self.topic_path = Mock()
53
+
54
+ dummy_publisher = DummyPublisher()
55
+
56
+ mocked_get_publisher.return_value = dummy_publisher
57
+
58
+ sample_task.enqueue(
59
+ "spec_arg",
60
+ "arg1",
61
+ )
62
+
63
+ dummy_publisher.topic_path.assert_called_once_with(
64
+ "project_name",
65
+ "other_default_topic_id",
66
+ )
67
+
68
+ @patch("django_tasks_pubsub.backend.get_publisher")
69
+ @patch("django_tasks_pubsub.backend.publish", return_value="")
70
+ def test_enqueuing_a_task_with_pubsub_topic(
71
+ self, mocked_publish_method, mocked_get_publisher
72
+ ):
73
+ class DummyPublisher:
74
+ def __init__(self):
75
+ self.topic_path = Mock()
76
+
77
+ dummy_publisher = DummyPublisher()
78
+
79
+ mocked_get_publisher.return_value = dummy_publisher
80
+
81
+ sample_task_with_pubsub_topic.enqueue(
82
+ "spec_arg",
83
+ "arg1",
84
+ )
85
+
86
+ dummy_publisher.topic_path.assert_called_once_with(
87
+ "project_name",
88
+ "test-topic-name",
89
+ )
90
+
91
+ @patch("django_tasks_pubsub.backend.publish", return_value="")
92
+ @patch(
93
+ "django_tasks_pubsub.PubSubBackend.get_topic_path",
94
+ return_value="expected/topic/path",
95
+ )
96
+ def test_enqueuing_task_should_use_value_from_get_topic_path(
97
+ self, mocked_get_topic_path, mocked_publish_method
98
+ ):
99
+ sample_task.enqueue(
100
+ "spec_arg",
101
+ "arg1",
102
+ "arg2",
103
+ )
104
+
105
+ self.assertEqual(mocked_publish_method.call_count, 1)
106
+ self.assertEqual(
107
+ mocked_publish_method.call_args.kwargs["topic"],
108
+ "expected/topic/path",
109
+ )
110
+
111
+
112
+ @override_settings(TASKS={"default": {"BACKEND": "django_tasks_pubsub.PubSubBackend"}})
113
+ class TestPubSubBackendEnqueuing(SimpleTestCase):
114
+ def setUp(self):
115
+ self.get_topic_path_patcher = patch(
116
+ "django_tasks_pubsub.backend.PubSubBackend.get_topic_path",
117
+ return_value="expected/topic/path",
118
+ )
119
+ self.get_topic_path_mock = self.get_topic_path_patcher.start()
120
+
121
+ def tearDown(self):
122
+ self.get_topic_path_patcher.stop()
123
+
124
+ @patch("django_tasks_pubsub.backend.publish", return_value="")
125
+ def test_enqueuing_a_task_with_the_pub_sub_backend_should_not_raise_an_error(
126
+ self, mocked_publish_method
127
+ ):
128
+ sample_task.enqueue(
129
+ "spec_arg",
130
+ "arg1",
131
+ "arg2",
132
+ kwarg1="val1",
133
+ kwarg2="val2",
134
+ )
135
+
136
+ self.assertEqual(mocked_publish_method.call_count, 1)
137
+
138
+ with self.subTest("payload data is serialized as expected"):
139
+ payload_data = mocked_publish_method.call_args.kwargs["data"]
140
+ payload = json.loads(payload_data.decode("utf-8"))
141
+
142
+ self.assertIn("task", payload)
143
+ self.assertIn("task_id", payload)
144
+ self.assertIn("enqueued_at", payload)
145
+ self.assertIn("args", payload)
146
+ self.assertIn("kwargs", payload)
147
+
148
+ datetime.fromisoformat(payload["enqueued_at"])
149
+
150
+ self.assertEqual(
151
+ payload["args"],
152
+ ["spec_arg", "arg1", "arg2"],
153
+ )
154
+ self.assertEqual(
155
+ payload["kwargs"],
156
+ {
157
+ "kwarg1": "val1",
158
+ "kwarg2": "val2",
159
+ },
160
+ )
161
+
162
+ self.assertEqual(payload["task"]["name"], "sample_task")
163
+ self.assertEqual(payload["task"]["takes_context"], False)
164
+ self.assertEqual(
165
+ payload["task"]["module_path"],
166
+ "tests.test_enqueing_with_pubsub_task_backend.sample_task",
167
+ )
168
+ self.assertEqual(
169
+ payload["task"]["name"],
170
+ "sample_task",
171
+ )
172
+
173
+ self.assertIsNone(
174
+ payload["task"]["run_after"],
175
+ )
176
+
177
+ @patch("django_tasks_pubsub.backend.publish", return_value="")
178
+ def test_enqueuing_a_task_with_context_with_the_pub_sub_backend_should_not_raise_an_error(
179
+ self, mocked_publish_method
180
+ ):
181
+ sample_task_with_context.enqueue(
182
+ "spec_arg",
183
+ "arg1",
184
+ "arg2",
185
+ kwarg1="val1",
186
+ kwarg2="val2",
187
+ )
188
+
189
+ self.assertEqual(mocked_publish_method.call_count, 1)
190
+
191
+
192
+ class TestPubSubTopicEnqueuingWithImmediateBackend(SimpleTestCase):
193
+ @override_settings(
194
+ TASKS={
195
+ "default": {"BACKEND": "django.tasks.backends.immediate.ImmediateBackend"}
196
+ }
197
+ )
198
+ def test_enqueuing_a_task_with_pubsub_topic_should_raise_an_error(self):
199
+ sample_task_with_pubsub_topic_with_watchdog.enqueue(
200
+ "arg1",
201
+ "arg2",
202
+ )
203
+
204
+ self.assertEqual(sample_task_with_pubsub_topic_watchdog.call_count, 1)
205
+
206
+ self.assertEqual(
207
+ sample_task_with_pubsub_topic_watchdog.call_args.args[0], "arg1"
208
+ )
209
+ self.assertEqual(
210
+ sample_task_with_pubsub_topic_watchdog.call_args.args[1], "arg2"
211
+ )
@@ -0,0 +1,120 @@
1
+ import base64
2
+ import json
3
+ from unittest.mock import patch, Mock
4
+
5
+ from django.tasks import task
6
+ from django.test import RequestFactory, SimpleTestCase
7
+
8
+ from django_tasks_pubsub.backend import Payload, TaskPayload
9
+ from django_tasks_pubsub.views import PubSubPushView
10
+
11
+ sample_task_function_mock = Mock()
12
+
13
+
14
+ @task
15
+ def sample_task_function(specified_arg, *args, **kwargs):
16
+ sample_task_function_mock(specified_arg, *args, **kwargs)
17
+
18
+
19
+ class TestDjangoTasksPubSubPushView(SimpleTestCase):
20
+ def setUp(self):
21
+ self.request_factory = RequestFactory()
22
+
23
+ def build_pubsub_push_request(self, payload: dict):
24
+ encoded_payload = base64.b64encode(
25
+ json.dumps(payload).encode("utf-8"),
26
+ ).decode("utf-8")
27
+
28
+ envelope = {
29
+ "message": {
30
+ "data": encoded_payload,
31
+ "messageId": "test-message-id",
32
+ "publishTime": "2026-05-12T10:00:00Z",
33
+ },
34
+ "subscription": "projects/test-project/subscriptions/test-subscription",
35
+ }
36
+
37
+ return self.request_factory.post(
38
+ "/pubsub/",
39
+ data=json.dumps(envelope),
40
+ content_type="application/json",
41
+ )
42
+
43
+ @patch("django_tasks_pubsub.views.dispatch")
44
+ def test_pubsub_view_calls_dispatch_with_expected_payload(self, mocked_dispatch):
45
+ payload = {
46
+ "task": {
47
+ "backend": "default",
48
+ "module_path": "django_tasks_pubsub.tests.test_pubsub_view",
49
+ "name": "sample_task",
50
+ "priority": 0,
51
+ "queue_name": "default",
52
+ "takes_context": False,
53
+ },
54
+ "task_id": "test-task-id",
55
+ "enqueued_at": "2026-05-12T10:00:00+00:00",
56
+ "args": [
57
+ "spec_arg",
58
+ "arg1",
59
+ "arg2",
60
+ ],
61
+ "kwargs": {
62
+ "kwarg1": "val1",
63
+ "kwarg2": "val2",
64
+ },
65
+ }
66
+
67
+ request = self.build_pubsub_push_request(payload)
68
+
69
+ response = PubSubPushView.as_view()(request)
70
+
71
+ self.assertEqual(response.status_code, 204)
72
+
73
+ with self.subTest("dispatch is called once"):
74
+ mocked_dispatch.assert_called_once()
75
+
76
+ with self.subTest("dispatch is called with payload object"):
77
+ mocked_dispatch.assert_called_once_with(
78
+ payload=Payload(
79
+ task=TaskPayload(**payload.pop("task")),
80
+ **payload,
81
+ )
82
+ )
83
+
84
+ def test_pubsub_view_calls_actual_task_function_with_expected_arguments(self):
85
+ payload = {
86
+ "task": {
87
+ "backend": "default",
88
+ "module_path": "tests.test_pubsub_view",
89
+ "name": "sample_task_function",
90
+ "priority": 0,
91
+ "queue_name": "default",
92
+ "takes_context": False,
93
+ },
94
+ "task_id": "test-task-id",
95
+ "enqueued_at": "2026-05-12T10:00:00+00:00",
96
+ "args": [
97
+ "spec_arg",
98
+ "arg1",
99
+ "arg2",
100
+ ],
101
+ "kwargs": {
102
+ "kwarg1": "val1",
103
+ "kwarg2": "val2",
104
+ },
105
+ }
106
+
107
+ request = self.build_pubsub_push_request(payload)
108
+
109
+ response = PubSubPushView.as_view()(request)
110
+
111
+ self.assertEqual(response.status_code, 204)
112
+
113
+ with self.subTest("actual task function is called with expected arguments"):
114
+ sample_task_function_mock.assert_called_once_with(
115
+ "spec_arg",
116
+ "arg1",
117
+ "arg2",
118
+ kwarg1="val1",
119
+ kwarg2="val2",
120
+ )