|
7 | 7 | from simstudio import SimStudioClient, SimStudioError, WorkflowExecutionResult, WorkflowStatus |
8 | 8 |
|
9 | 9 |
|
10 | | -def v2_execution_response(output=None): |
| 10 | +def v2_execution_response(output=None, status="completed", error=None): |
11 | 11 | return { |
12 | 12 | "data": { |
13 | 13 | "runId": "execution-123", |
14 | 14 | "workflowId": "workflow-id", |
15 | | - "status": "completed", |
| 15 | + "status": status, |
16 | 16 | "output": {} if output is None else output, |
17 | | - "error": None, |
| 17 | + "error": error, |
| 18 | + "startedAt": "2026-08-11T12:00:00.000Z", |
| 19 | + "endedAt": "2026-08-11T12:00:00.010Z", |
18 | 20 | "durationMs": 10 |
19 | 21 | } |
20 | 22 | } |
21 | 23 |
|
22 | 24 |
|
| 25 | +def mock_execution_post(mock_post, status="completed", error=None, headers=None): |
| 26 | + """Wire a mocked 200 v2 execution response with the given terminal status.""" |
| 27 | + mock_response = Mock() |
| 28 | + mock_response.ok = True |
| 29 | + mock_response.status_code = 200 |
| 30 | + mock_response.json.return_value = v2_execution_response(status=status, error=error) |
| 31 | + mock_response.headers.get.side_effect = lambda h: (headers or {}).get(h) |
| 32 | + mock_post.return_value = mock_response |
| 33 | + return mock_response |
| 34 | + |
| 35 | + |
23 | 36 | def test_simstudio_client_initialization(): |
24 | 37 | """Test SimStudioClient initialization.""" |
25 | 38 | client = SimStudioClient(api_key="test-api-key", base_url="https://test.sim.ai") |
@@ -161,10 +174,58 @@ def test_sync_execution_returns_result(mock_post): |
161 | 174 | ) |
162 | 175 |
|
163 | 176 | assert result.success is True |
| 177 | + assert result.status == "completed" |
164 | 178 | assert result.output == {"result": "completed"} |
| 179 | + assert result.metadata == { |
| 180 | + "duration": 10, |
| 181 | + "runId": "execution-123", |
| 182 | + "startTime": "2026-08-11T12:00:00.000Z", |
| 183 | + "endTime": "2026-08-11T12:00:00.010Z" |
| 184 | + } |
165 | 185 | assert not hasattr(result, 'task_id') |
166 | 186 |
|
167 | 187 |
|
| 188 | +@patch('simstudio.requests.Session.post') |
| 189 | +def test_sync_execution_cancelled_is_not_success(mock_post): |
| 190 | + """A run cancelled out of band is not a success, matching the TypeScript SDK.""" |
| 191 | + mock_execution_post(mock_post, status="cancelled") |
| 192 | + |
| 193 | + client = SimStudioClient(api_key="test-api-key") |
| 194 | + result = client.execute_workflow("workflow-id", {}) |
| 195 | + |
| 196 | + assert result.success is False |
| 197 | + assert result.status == "cancelled" |
| 198 | + |
| 199 | + |
| 200 | +@patch('simstudio.requests.Session.post') |
| 201 | +def test_sync_execution_failed_is_not_success(mock_post): |
| 202 | + """A failed run is not a success and surfaces the server's error message.""" |
| 203 | + mock_execution_post( |
| 204 | + mock_post, |
| 205 | + status="failed", |
| 206 | + error={"code": "BLOCK_EXECUTION_FAILED", "message": "Invalid credentials"} |
| 207 | + ) |
| 208 | + |
| 209 | + client = SimStudioClient(api_key="test-api-key") |
| 210 | + result = client.execute_workflow("workflow-id", {}) |
| 211 | + |
| 212 | + assert result.success is False |
| 213 | + assert result.status == "failed" |
| 214 | + assert result.error == "Invalid credentials" |
| 215 | + |
| 216 | + |
| 217 | +@patch('simstudio.requests.Session.post') |
| 218 | +def test_sync_execution_paused_is_success(mock_post): |
| 219 | + """A paused run is still a success -- it is waiting, not broken.""" |
| 220 | + mock_execution_post(mock_post, status="paused") |
| 221 | + |
| 222 | + client = SimStudioClient(api_key="test-api-key") |
| 223 | + result = client.execute_workflow("workflow-id", {}) |
| 224 | + |
| 225 | + assert result.success is True |
| 226 | + assert result.status == "paused" |
| 227 | + |
| 228 | + |
168 | 229 | @patch('simstudio.requests.Session.post') |
169 | 230 | def test_async_header_not_set_when_false(mock_post): |
170 | 231 | """Test X-Execution-Mode header is not set when async_execution is None.""" |
@@ -497,6 +558,48 @@ def test_get_rate_limit_info_after_api_call(mock_post): |
497 | 558 | assert info.reset == 1704067200 |
498 | 559 |
|
499 | 560 |
|
| 561 | +@patch('simstudio.requests.Session.post') |
| 562 | +def test_rate_limit_reset_accepts_iso_timestamp(mock_post): |
| 563 | + """The v2 API sends X-RateLimit-Reset as an ISO 8601 timestamp, not an epoch int.""" |
| 564 | + mock_execution_post(mock_post, headers={ |
| 565 | + 'x-ratelimit-limit': '100', |
| 566 | + 'x-ratelimit-remaining': '99', |
| 567 | + 'x-ratelimit-reset': '2024-01-01T00:00:00.000Z' |
| 568 | + }) |
| 569 | + |
| 570 | + client = SimStudioClient(api_key="test-api-key") |
| 571 | + result = client.execute_workflow("workflow-id", {}) |
| 572 | + |
| 573 | + assert result.success is True |
| 574 | + info = client.get_rate_limit_info() |
| 575 | + assert info is not None |
| 576 | + assert info.reset == 1704067200000 |
| 577 | + |
| 578 | + |
| 579 | +@pytest.mark.parametrize('reset_header', ['not-a-timestamp', '²']) |
| 580 | +@patch('simstudio.requests.Session.post') |
| 581 | +def test_rate_limit_reset_tolerates_unparseable_value(mock_post, reset_header): |
| 582 | + """ |
| 583 | + An unrecognised quota hint reports 0 rather than failing the execution. |
| 584 | +
|
| 585 | + '²' covers the digit-like characters str.isdigit() accepts but int() |
| 586 | + rejects. |
| 587 | + """ |
| 588 | + mock_execution_post(mock_post, headers={ |
| 589 | + 'x-ratelimit-limit': '100', |
| 590 | + 'x-ratelimit-remaining': '99', |
| 591 | + 'x-ratelimit-reset': reset_header |
| 592 | + }) |
| 593 | + |
| 594 | + client = SimStudioClient(api_key="test-api-key") |
| 595 | + result = client.execute_workflow("workflow-id", {}) |
| 596 | + |
| 597 | + assert result.success is True |
| 598 | + info = client.get_rate_limit_info() |
| 599 | + assert info is not None |
| 600 | + assert info.reset == 0 |
| 601 | + |
| 602 | + |
500 | 603 | @patch('simstudio.requests.Session.get') |
501 | 604 | def test_get_usage_limits_success(mock_get): |
502 | 605 | """Test getting usage limits.""" |
|
0 commit comments