-
Notifications
You must be signed in to change notification settings - Fork 30
Expand file tree
/
Copy pathtest_event_handler.py
More file actions
269 lines (218 loc) · 10.2 KB
/
Copy pathtest_event_handler.py
File metadata and controls
269 lines (218 loc) · 10.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
import os
import pathlib
import sys
import time
import unittest
from unittest import mock
# Set required env vars before the module is imported
os.environ.setdefault("BASE_URL", "localhost")
os.environ.setdefault("REDIRECT_URL", "example.com")
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent / "sync-page"))
with mock.patch(
"sync2jira.main.load_config", return_value={"sync2jira": {"map": {"github": {}}}}
):
import event_handler as eh
PATH = "event_handler."
def _make_job(status="in_progress", repos=None, finished_at=None, error=None):
"""Helper to build a job dict matching the shape used by _run_sync."""
return {
"status": status,
"repos": repos if repos is not None else ["org/repo"],
"error": error,
"finished_at": finished_at,
}
class TestHandleEvent(unittest.TestCase):
"""Tests for the /handle-event POST endpoint."""
def setUp(self):
eh._jobs.clear()
eh._jobs_repo.clear()
self.client = eh.app.test_client()
@mock.patch(PATH + "_cleanup_expired_jobs")
@mock.patch(PATH + "render_template", return_value="")
def test_no_repos_selected_returns_failure_page(self, mock_render, _mock_cleanup):
resp = self.client.post("/handle-event", data={})
self.assertEqual(resp.status_code, 400)
mock_render.assert_called_once_with("sync-page-failure.jinja", url=mock.ANY)
@mock.patch(PATH + "_cleanup_expired_jobs")
@mock.patch(PATH + "render_template", return_value="")
def test_all_repos_off_returns_failure_page(self, mock_render, _mock_cleanup):
resp = self.client.post("/handle-event", data={"org/repo": "off"})
self.assertEqual(resp.status_code, 400)
mock_render.assert_called_once_with("sync-page-failure.jinja", url=mock.ANY)
@mock.patch(PATH + "_cleanup_expired_jobs")
@mock.patch(PATH + "render_template", return_value="")
def test_already_syncing_same_repo_returns_failure_with_error(
self, mock_render, _mock_cleanup
):
# repo-b overlaps; repo-a is new; repo-c is an unrelated concurrent sync
with eh._jobs_repo_lock:
eh._jobs_repo.update(["org/repo-b", "org/repo-c"])
resp = self.client.post(
"/handle-event", data={"org/repo-a": "on", "org/repo-b": "on"}
)
self.assertEqual(resp.status_code, 409)
_, kwargs = mock_render.call_args
self.assertIn("org/repo-b", kwargs["error"]) # the conflicting repo is named
self.assertNotIn(
"org/repo-a", kwargs["error"]
) # the non-conflicting repo is not
@mock.patch(PATH + "_cleanup_expired_jobs")
@mock.patch("threading.Thread")
@mock.patch(PATH + "render_template", return_value="")
def test_valid_repos_creates_job_and_starts_thread(
self, mock_render, mock_thread, _mock_cleanup
):
resp = self.client.post("/handle-event", data={"org/repo": "on"})
self.assertEqual(resp.status_code, 200)
mock_render.assert_called_once_with(
"sync-page-in-progress.jinja",
job_id=mock.ANY,
synced_repos=["org/repo"],
url=mock.ANY,
)
# Job created in in_progress state
with eh._jobs_lock:
self.assertEqual(len(eh._jobs), 1)
job = next(iter(eh._jobs.values()))
self.assertEqual(job["status"], "in_progress")
self.assertEqual(job["repos"], ["org/repo"])
# Repo locked for the duration of the sync
with eh._jobs_repo_lock:
self.assertIn("org/repo", eh._jobs_repo)
# Background thread created and started with correct arguments
mock_thread.assert_called_once_with(
target=eh._run_sync, args=(mock.ANY, ["org/repo"]), daemon=True
)
mock_thread.return_value.start.assert_called_once()
class TestJobStatus(unittest.TestCase):
"""Tests for the /status/<job_id> GET endpoint."""
def setUp(self):
eh._jobs.clear()
self.client = eh.app.test_client()
def test_unknown_job_returns_404(self):
resp = self.client.get("/status/nonexistent")
self.assertEqual(resp.status_code, 404)
self.assertEqual(resp.get_json()["status"], "not_found")
def test_in_progress_job_returns_status(self):
eh._jobs["j1"] = _make_job("in_progress")
resp = self.client.get("/status/j1")
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.get_json()["status"], "in_progress")
def test_in_progress_job_not_removed_after_read(self):
eh._jobs["j1"] = _make_job("in_progress")
self.client.get("/status/j1")
with eh._jobs_lock:
self.assertIn("j1", eh._jobs)
def test_completed_job_returns_status(self):
eh._jobs["j1"] = _make_job("completed", finished_at=time.monotonic())
resp = self.client.get("/status/j1")
self.assertEqual(resp.get_json()["status"], "completed")
def test_completed_job_readable_on_second_poll(self):
eh._jobs["j1"] = _make_job("completed", finished_at=time.monotonic())
self.client.get("/status/j1")
resp = self.client.get("/status/j1")
self.assertEqual(resp.get_json()["status"], "completed")
def test_failed_job_returns_error_message(self):
eh._jobs["j1"] = _make_job(
"failed", finished_at=time.monotonic(), error="connection refused"
)
resp = self.client.get("/status/j1")
data = resp.get_json()
self.assertEqual(data["status"], "failed")
self.assertEqual(data["error"], "connection refused")
def test_finished_at_not_exposed_in_response(self):
eh._jobs["j1"] = _make_job("completed", finished_at=time.monotonic())
resp = self.client.get("/status/j1")
self.assertNotIn("finished_at", resp.get_json())
class TestRunSync(unittest.TestCase):
"""Tests for _run_sync — called directly to avoid threading complexity."""
def setUp(self):
eh._jobs.clear()
eh._jobs_repo.clear()
@mock.patch(PATH + "initialize_issues")
@mock.patch(PATH + "initialize_pr")
def test_success(self, _mock_pr, _mock_issues):
repos = ["org/repo-a", "org/repo-b"]
eh._jobs["j1"] = _make_job("in_progress", repos=repos)
with eh._jobs_repo_lock:
# Simulate a concurrent sync holding an unrelated repo
eh._jobs_repo.update(repos + ["org/other-sync"])
before = time.monotonic()
eh._run_sync("j1", repos)
self.assertEqual(eh._jobs["j1"]["status"], "completed")
self.assertIsNone(eh._jobs["j1"]["error"])
self.assertIsInstance(eh._jobs["j1"]["finished_at"], float)
self.assertGreaterEqual(eh._jobs["j1"]["finished_at"], before)
with eh._jobs_repo_lock:
self.assertNotIn("org/repo-a", eh._jobs_repo) # job's repos released
self.assertNotIn("org/repo-b", eh._jobs_repo)
self.assertIn("org/other-sync", eh._jobs_repo) # unrelated sync untouched
@mock.patch(
PATH + "initialize_issues", side_effect=RuntimeError("connection refused")
)
@mock.patch(PATH + "initialize_pr")
def test_failure_via_initialize_issues(self, _mock_pr, _mock_issues):
repos = ["org/repo"]
eh._jobs["j1"] = _make_job("in_progress", repos=repos)
with eh._jobs_repo_lock:
eh._jobs_repo.update(repos + ["org/other-sync"])
eh._run_sync("j1", repos)
self.assertEqual(eh._jobs["j1"]["status"], "failed")
self.assertEqual(eh._jobs["j1"]["error"], "connection refused")
self.assertIsNotNone(eh._jobs["j1"]["finished_at"])
with eh._jobs_repo_lock:
self.assertNotIn("org/repo", eh._jobs_repo)
self.assertIn("org/other-sync", eh._jobs_repo)
@mock.patch(PATH + "initialize_issues")
@mock.patch(PATH + "initialize_pr", side_effect=RuntimeError("pr fetch failed"))
def test_failure_via_initialize_pr(self, _mock_pr, _mock_issues):
repos = ["org/repo"]
eh._jobs["j1"] = _make_job("in_progress", repos=repos)
with eh._jobs_repo_lock:
eh._jobs_repo.update(repos + ["org/other-sync"])
eh._run_sync("j1", repos)
self.assertEqual(eh._jobs["j1"]["status"], "failed")
self.assertEqual(eh._jobs["j1"]["error"], "pr fetch failed")
self.assertIsNotNone(eh._jobs["j1"]["finished_at"])
with eh._jobs_repo_lock:
self.assertNotIn("org/repo", eh._jobs_repo)
self.assertIn("org/other-sync", eh._jobs_repo)
class TestCleanupExpiredJobs(unittest.TestCase):
"""Tests for _cleanup_expired_jobs — the TTL-based expiry logic."""
def setUp(self):
eh._jobs.clear()
def test_expired_completed_job_is_removed(self):
eh._jobs["old"] = _make_job(
"completed", finished_at=time.monotonic() - eh.JOB_TTL_SECONDS - 1
)
eh._cleanup_expired_jobs()
self.assertNotIn("old", eh._jobs)
def test_expired_failed_job_is_removed(self):
eh._jobs["old"] = _make_job(
"failed", finished_at=time.monotonic() - eh.JOB_TTL_SECONDS - 1
)
eh._cleanup_expired_jobs()
self.assertNotIn("old", eh._jobs)
def test_fresh_terminal_job_is_retained(self):
eh._jobs["new"] = _make_job("completed", finished_at=time.monotonic())
eh._cleanup_expired_jobs()
self.assertIn("new", eh._jobs)
def test_in_progress_job_never_expired(self):
eh._jobs["running"] = _make_job("in_progress")
eh._cleanup_expired_jobs()
self.assertIn("running", eh._jobs)
def test_only_expired_jobs_removed(self):
eh._jobs["old"] = _make_job(
"completed", finished_at=time.monotonic() - eh.JOB_TTL_SECONDS - 1
)
eh._jobs["new"] = _make_job("completed", finished_at=time.monotonic())
eh._jobs["running"] = _make_job("in_progress")
eh._cleanup_expired_jobs()
self.assertNotIn("old", eh._jobs)
self.assertIn("new", eh._jobs)
self.assertIn("running", eh._jobs)
def test_empty_jobs_dict_does_not_raise(self):
eh._cleanup_expired_jobs() # should not raise
def test_job_ttl_constant_is_positive(self):
self.assertIsInstance(eh.JOB_TTL_SECONDS, int)
self.assertGreater(eh.JOB_TTL_SECONDS, 0)