Skip to content

Commit 29001e5

Browse files
committed
Add budget-window simulation integration test
1 parent 2b6ba20 commit 29001e5

1 file changed

Lines changed: 163 additions & 0 deletions

File tree

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"""
2+
Integration tests for Modal-based budget-window batches.
3+
4+
These tests run against the staging Modal deployment and verify that the
5+
gateway can spawn the parent budget-window worker, the parent can spawn child
6+
simulation workers, and the completed batch result has the public response
7+
shape expected by API consumers.
8+
"""
9+
10+
import json
11+
import time
12+
from http import HTTPStatus
13+
14+
import pytest
15+
16+
from policyengine_api_simulation_client import AuthenticatedClient, Client
17+
from policyengine_api_simulation_client.api.default import (
18+
get_budget_window_job_status_budget_window_jobs_batch_job_id_get,
19+
submit_budget_window_batch_simulate_economy_budget_window_post,
20+
)
21+
from policyengine_api_simulation_client.models import (
22+
BudgetWindowBatchRequest,
23+
BudgetWindowBatchStatusResponse,
24+
BudgetWindowBatchSubmitResponse,
25+
BudgetWindowResult,
26+
)
27+
from policyengine_api_simulation_client.types import Unset
28+
29+
30+
def _decode_response_content(content: bytes) -> str:
31+
try:
32+
return json.dumps(json.loads(content), sort_keys=True)
33+
except (json.JSONDecodeError, UnicodeDecodeError):
34+
return content.decode("utf-8", errors="replace")
35+
36+
37+
def poll_budget_window_batch(
38+
client: Client | AuthenticatedClient,
39+
batch_job_id: str,
40+
max_wait_seconds: float,
41+
poll_interval: float,
42+
) -> BudgetWindowBatchStatusResponse:
43+
"""
44+
Poll a budget-window batch until it reaches a terminal state.
45+
"""
46+
deadline = time.monotonic() + max_wait_seconds
47+
last_status_code: HTTPStatus | None = None
48+
last_content = b""
49+
50+
while time.monotonic() < deadline:
51+
response = get_budget_window_job_status_budget_window_jobs_batch_job_id_get.sync_detailed(
52+
batch_job_id=batch_job_id, client=client
53+
)
54+
last_status_code = response.status_code
55+
last_content = response.content
56+
57+
if response.status_code == HTTPStatus.ACCEPTED:
58+
time.sleep(poll_interval)
59+
continue
60+
61+
if response.status_code == HTTPStatus.OK:
62+
assert isinstance(response.parsed, BudgetWindowBatchStatusResponse), (
63+
f"Unexpected response type: {type(response.parsed)}"
64+
)
65+
assert response.parsed.status == "complete", (
66+
f"Unexpected budget-window status: {response.parsed}"
67+
)
68+
return response.parsed
69+
70+
if response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR:
71+
raise AssertionError(
72+
"Budget-window batch failed: "
73+
f"{_decode_response_content(response.content)}"
74+
)
75+
76+
raise AssertionError(
77+
"Unexpected budget-window poll status "
78+
f"{response.status_code}: {_decode_response_content(response.content)}"
79+
)
80+
81+
raise TimeoutError(
82+
f"Budget-window batch {batch_job_id} did not complete within "
83+
f"{max_wait_seconds}s; last response was "
84+
f"{last_status_code}: {_decode_response_content(last_content)}"
85+
)
86+
87+
88+
@pytest.mark.beta_only
89+
def test_budget_window_multi_year_batch_completes(
90+
client: Client | AuthenticatedClient,
91+
us_model_version: str,
92+
max_wait_seconds: float,
93+
poll_interval: float,
94+
):
95+
"""
96+
Given a two-year US budget-window request
97+
When the batch is submitted and polled to completion
98+
Then the response contains 2026 and 2027 annual impacts plus totals.
99+
"""
100+
request = BudgetWindowBatchRequest.from_dict(
101+
{
102+
"country": "us",
103+
"version": us_model_version,
104+
"region": "us",
105+
"scope": "macro",
106+
"reform": {
107+
"gov.irs.credits.ctc.refundable.fully_refundable": {
108+
"2023-01-01.2100-12-31": True
109+
}
110+
},
111+
"subsample": 200,
112+
"data": "gs://policyengine-us-data/enhanced_cps_2024.h5",
113+
"start_year": "2026",
114+
"window_size": 2,
115+
"max_parallel": 2,
116+
}
117+
)
118+
119+
submit_response = (
120+
submit_budget_window_batch_simulate_economy_budget_window_post.sync_detailed(
121+
client=client,
122+
body=request,
123+
)
124+
)
125+
126+
assert submit_response.status_code == HTTPStatus.OK, (
127+
"Unexpected submit status "
128+
f"{submit_response.status_code}: "
129+
f"{_decode_response_content(submit_response.content)}"
130+
)
131+
assert isinstance(submit_response.parsed, BudgetWindowBatchSubmitResponse), (
132+
f"Unexpected response type: {type(submit_response.parsed)}"
133+
)
134+
assert submit_response.parsed.status == "submitted"
135+
assert submit_response.parsed.version == us_model_version
136+
137+
batch_job_id = submit_response.parsed.batch_job_id
138+
assert submit_response.parsed.poll_url == f"/budget-window-jobs/{batch_job_id}"
139+
140+
completed = poll_budget_window_batch(
141+
client=client,
142+
batch_job_id=batch_job_id,
143+
max_wait_seconds=max_wait_seconds,
144+
poll_interval=poll_interval,
145+
)
146+
147+
assert completed.status == "complete"
148+
assert completed.progress == 100
149+
assert completed.error is None or isinstance(completed.error, Unset)
150+
assert isinstance(completed.result, BudgetWindowResult)
151+
152+
result = completed.result
153+
assert result.kind == "budgetWindow"
154+
assert result.start_year == "2026"
155+
assert result.end_year == "2027"
156+
assert result.window_size == 2
157+
annual_impacts = result.annual_impacts
158+
assert not isinstance(annual_impacts, Unset)
159+
assert [impact.year for impact in annual_impacts] == ["2026", "2027"]
160+
assert result.totals.year == "Total"
161+
assert all(
162+
isinstance(impact.budgetary_impact, int | float) for impact in annual_impacts
163+
)

0 commit comments

Comments
 (0)