|
14 | 14 |
|
15 | 15 | import testflinger_agent |
16 | 16 | from testflinger_agent.client import TestflingerClient as _TestflingerClient |
| 17 | +from testflinger_agent.errors import TFServerError |
17 | 18 | from testflinger_agent.handlers import FileLogHandler |
18 | 19 | from testflinger_agent.job import ( |
19 | 20 | TestflingerJob as _TestflingerJob, |
@@ -245,6 +246,79 @@ def test_allocate_phase_global_timeout_exit_values( |
245 | 246 | outcome_data["status"]["allocate"] == -signal.SIGKILL.value % 256 |
246 | 247 | ) |
247 | 248 |
|
| 249 | + @patch.object(_TestflingerJob, "allocate_phase") |
| 250 | + def test_allocate_phase_normal_completion( |
| 251 | + self, mock_allocate, client, tmp_path, requests_mock |
| 252 | + ): |
| 253 | + """Test allocate phase completes normally without timeout.""" |
| 254 | + self.config["allocate_command"] = "/bin/true" |
| 255 | + fake_job_data = { |
| 256 | + "global_timeout": 60, |
| 257 | + "allocate_data": {"allocate": True}, |
| 258 | + } |
| 259 | + outcome_file_path = tmp_path / "testflinger-outcome.json" |
| 260 | + outcome_file_path.write_text("{}") |
| 261 | + |
| 262 | + requests_mock.post(rmock.ANY, status_code=HTTPStatus.OK) |
| 263 | + requests_mock.get(rmock.ANY, status_code=HTTPStatus.OK) |
| 264 | + |
| 265 | + # allocate_phase returns (None, None) on normal completion |
| 266 | + mock_allocate.return_value = (None, None) |
| 267 | + job = _TestflingerJob(fake_job_data, client) |
| 268 | + exitcode, exit_event, exit_reason = job.run_test_phase( |
| 269 | + "allocate", tmp_path |
| 270 | + ) |
| 271 | + |
| 272 | + assert exitcode == 0 |
| 273 | + assert exit_event != TestEvent.GLOBAL_TIMEOUT |
| 274 | + |
| 275 | + @patch("testflinger_agent.job.time.sleep") |
| 276 | + @patch.object(_TestflingerJob, "wait_for_completion") |
| 277 | + def test_allocate_phase_post_result_retry( |
| 278 | + self, mock_wait, mock_sleep, client, tmp_path, requests_mock |
| 279 | + ): |
| 280 | + """Test that allocate_phase retries posting device info on failure.""" |
| 281 | + mock_wait.return_value = (None, None) |
| 282 | + requests_mock.post( |
| 283 | + rmock.ANY, |
| 284 | + [ |
| 285 | + {"exc": TFServerError(HTTPStatus.INTERNAL_SERVER_ERROR)}, |
| 286 | + {"status_code": HTTPStatus.OK}, |
| 287 | + ], |
| 288 | + ) |
| 289 | + requests_mock.get(rmock.ANY, status_code=HTTPStatus.OK) |
| 290 | + |
| 291 | + checker = GlobalTimeoutChecker(60) |
| 292 | + job = _TestflingerJob({}, client) |
| 293 | + job.allocate_phase(tmp_path, checker) |
| 294 | + |
| 295 | + # post_result should have been called twice (one failure + one retry) |
| 296 | + assert requests_mock.call_count >= 2 |
| 297 | + mock_sleep.assert_called_once_with(60) |
| 298 | + |
| 299 | + @pytest.mark.timeout(5) |
| 300 | + def test_wait_for_completion_parent_completes(self, client, mocker): |
| 301 | + """Test wait_for_completion exits when the parent job completes.""" |
| 302 | + parent_job_id = str(uuid.uuid1()) |
| 303 | + this_job_id = str(uuid.uuid1()) |
| 304 | + fake_job_data = {"parent_job_id": parent_job_id, "job_id": this_job_id} |
| 305 | + job = _TestflingerJob(fake_job_data, client) |
| 306 | + |
| 307 | + # First call checks this job (allocated), |
| 308 | + # second call checks parent job (completed) |
| 309 | + mocker.patch.object( |
| 310 | + client, |
| 311 | + "check_job_state", |
| 312 | + side_effect=[JobState.ALLOCATED, JobState.COMPLETED], |
| 313 | + ) |
| 314 | + |
| 315 | + checker = GlobalTimeoutChecker(60) |
| 316 | + event, reason = job.wait_for_completion(checker) |
| 317 | + |
| 318 | + # Assert that the exit event and reason indicate normal completion |
| 319 | + assert event is None |
| 320 | + assert reason is None |
| 321 | + |
248 | 322 | def test_get_device_info(self, client, tmp_path): |
249 | 323 | """Test job can read from device-info file.""" |
250 | 324 | # Create device-info.json to simulate device-connector |
|
0 commit comments