Skip to content

Commit a1428a7

Browse files
committed
Add budget-window coverage gaps
1 parent bd4b27e commit a1428a7

8 files changed

Lines changed: 218 additions & 2 deletions

File tree

.github/scripts/modal-run-integ-tests.sh

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
#!/bin/bash
22
# Run simulation integration tests
3-
# Usage: ./modal-run-integ-tests.sh <environment> <base-url> [us-version]
3+
# Usage: ./modal-run-integ-tests.sh <environment> <base-url> [us-version] [uk-version]
44
# Environment: beta runs all tests, prod excludes beta_only tests
55

66
set -euo pipefail
77

88
ENVIRONMENT="${1:?Environment required (beta or prod)}"
99
BASE_URL="${2:?Base URL required}"
1010
US_VERSION="${3:-}"
11+
UK_VERSION="${4:-}"
1112

1213
truthy() {
1314
case "${1:-}" in
@@ -115,6 +116,10 @@ if [ -n "$US_VERSION" ]; then
115116
export simulation_integ_test_us_model_version="$US_VERSION"
116117
fi
117118

119+
if [ -n "$UK_VERSION" ]; then
120+
export simulation_integ_test_uk_model_version="$UK_VERSION"
121+
fi
122+
118123
if [ "$ENVIRONMENT" = "beta" ]; then
119124
echo "Running all simulation integration tests (including beta_only)"
120125
uv run pytest tests/simulation/ -v

.github/workflows/modal-deploy.reusable.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ on:
1515
simulation_api_url:
1616
description: 'The deployed simulation API URL'
1717
value: ${{ jobs.deploy.outputs.simulation_api_url }}
18+
us_version:
19+
description: 'The deployed policyengine-us package version'
20+
value: ${{ jobs.deploy.outputs.us_version }}
21+
uk_version:
22+
description: 'The deployed policyengine-uk package version'
23+
value: ${{ jobs.deploy.outputs.uk_version }}
1824

1925
jobs:
2026
deploy:
@@ -24,6 +30,7 @@ jobs:
2430
outputs:
2531
simulation_api_url: ${{ steps.get-url.outputs.simulation_api_url }}
2632
us_version: ${{ steps.versions.outputs.us_version }}
33+
uk_version: ${{ steps.versions.outputs.uk_version }}
2734

2835
steps:
2936
- name: Checkout repo
@@ -114,4 +121,4 @@ jobs:
114121
GATEWAY_AUTH_CLIENT_ID: ${{ secrets.GATEWAY_AUTH_CLIENT_ID }}
115122
GATEWAY_AUTH_CLIENT_SECRET: ${{ secrets.GATEWAY_AUTH_CLIENT_SECRET }}
116123
GATEWAY_AUTH_REQUIRED: ${{ vars.GATEWAY_AUTH_REQUIRED }}
117-
run: .github/scripts/modal-run-integ-tests.sh "${{ inputs.environment }}" "${{ needs.deploy.outputs.simulation_api_url }}" "${{ needs.deploy.outputs.us_version }}"
124+
run: .github/scripts/modal-run-integ-tests.sh "${{ inputs.environment }}" "${{ needs.deploy.outputs.simulation_api_url }}" "${{ needs.deploy.outputs.us_version }}" "${{ needs.deploy.outputs.uk_version }}"

projects/policyengine-api-simulation/tests/gateway/test_models.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
SimulationRequest,
1616
JobSubmitResponse,
1717
JobStatusResponse,
18+
_default_missing_state_tax_revenue_impact,
19+
_enforce_max_payload_size,
20+
_move_internal_telemetry_alias,
21+
_strip_internal_passthrough_fields,
1822
)
1923
from tests.fixtures.budget_window_outputs import make_single_year_macro_output
2024

@@ -113,6 +117,15 @@ def test_ping_response_serializes_correctly(self):
113117
class TestSimulationRequest:
114118
"""Tests for SimulationRequest model."""
115119

120+
def test_request_pre_validators_ignore_non_dict_payloads(self):
121+
"""Defensive pre-validators must leave non-mapping input to Pydantic."""
122+
123+
value = "not-a-request-object"
124+
125+
assert _move_internal_telemetry_alias(value) == value
126+
assert _strip_internal_passthrough_fields(value) == value
127+
assert _enforce_max_payload_size(value) == value
128+
116129
def test_simulation_request_requires_country(self):
117130
"""
118131
Given no country
@@ -252,6 +265,13 @@ def test_simulation_request_rejects_payload_just_above_256kb(self):
252265
with pytest.raises(ValidationError, match="too large"):
253266
SimulationRequest(**payload)
254267

268+
def test_simulation_request_size_cap_defers_non_json_serializable_payloads(self):
269+
"""The size cap is best-effort; non-JSON objects fail later."""
270+
271+
payload = {("tuple", "key"): "not-json-serializable"}
272+
273+
assert _enforce_max_payload_size(payload) is payload
274+
255275
def test_simulation_request_accepts_typed_telemetry_envelope(self):
256276
"""
257277
Given a telemetry envelope
@@ -555,6 +575,34 @@ def test_budget_window_batch_submit_response_serializes_correctly(self):
555575
class TestBudgetWindowBatchStatusResponse:
556576
"""Tests for budget-window batch status responses."""
557577

578+
def test_single_year_macro_output_budget_normalizer_defensive_branches(self):
579+
"""The state-tax default only applies to object outputs with budgets."""
580+
581+
raw_value = "not-a-macro-output"
582+
assert _default_missing_state_tax_revenue_impact(raw_value) == raw_value
583+
584+
no_budget = {"poverty": {}}
585+
assert _default_missing_state_tax_revenue_impact(no_budget) is no_budget
586+
587+
non_object_budget = {"budget": "not-an-object"}
588+
assert (
589+
_default_missing_state_tax_revenue_impact(non_object_budget)
590+
is non_object_budget
591+
)
592+
593+
existing_state_tax = {"budget": {"state_tax_revenue_impact": 12}}
594+
assert (
595+
_default_missing_state_tax_revenue_impact(existing_state_tax)
596+
is existing_state_tax
597+
)
598+
599+
missing_state_tax = {"budget": {"tax_revenue_impact": 12}}
600+
normalized = _default_missing_state_tax_revenue_impact(missing_state_tax)
601+
602+
assert normalized is not missing_state_tax
603+
assert missing_state_tax["budget"].get("state_tax_revenue_impact") is None
604+
assert normalized["budget"]["state_tax_revenue_impact"] == 0.0
605+
558606
def test_budget_window_result_requires_years_and_outputs_by_year(self):
559607
with pytest.raises(ValidationError):
560608
BudgetWindowResult(

projects/policyengine-api-simulation/tests/test_budget_window_results.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,55 @@ def test_validate_single_year_output_rejects_malformed_child_result():
8888
)
8989

9090

91+
def test_validate_single_year_output_rejects_non_object_child_result():
92+
with pytest.raises(
93+
ValueError,
94+
match="Malformed budget-window child result: expected object for 2026",
95+
):
96+
validate_single_year_output(
97+
simulation_year="2026",
98+
child_result="not-an-object",
99+
)
100+
101+
102+
def test_validate_single_year_output_rejects_non_object_budget():
103+
child_result = make_single_year_macro_output(
104+
tax_revenue_impact=100,
105+
state_tax_revenue_impact=40,
106+
benefit_spending_impact=20,
107+
budgetary_impact=80,
108+
)
109+
child_result["budget"] = "not-an-object"
110+
111+
with pytest.raises(
112+
ValueError,
113+
match="Malformed budget-window child result: missing budget object",
114+
):
115+
validate_single_year_output(
116+
simulation_year="2026",
117+
child_result=child_result,
118+
)
119+
120+
121+
def test_validate_single_year_output_wraps_model_shape_errors():
122+
child_result = make_single_year_macro_output(
123+
tax_revenue_impact=100,
124+
state_tax_revenue_impact=40,
125+
benefit_spending_impact=20,
126+
budgetary_impact=80,
127+
)
128+
child_result["decile"] = "not-an-object"
129+
130+
with pytest.raises(
131+
ValueError,
132+
match="Malformed budget-window child result for 2026",
133+
):
134+
validate_single_year_output(
135+
simulation_year="2026",
136+
child_result=child_result,
137+
)
138+
139+
91140
def test_validate_single_year_output_rejects_malformed_state_tax_value():
92141
child_result = make_single_year_macro_output(
93142
tax_revenue_impact=100,

projects/policyengine-api-simulation/tests/test_budget_window_scheduler.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,16 @@
1515
from fastapi.testclient import TestClient
1616

1717
import src.modal.budget_window_batch as batch_module
18+
from src.modal.budget_window_context import BudgetWindowBatchContext
1819
import src.modal.budget_window_scheduler as scheduler_module
1920
import src.modal.budget_window_state as state_module
2021
from fixtures.gateway.shared import create_gateway_app
2122
from src.modal.gateway import endpoints
23+
from src.modal.gateway.models import (
24+
BatchChildJobStatus,
25+
BudgetWindowBatchRequest,
26+
PolicyEngineBundle,
27+
)
2228
from tests.fixtures.budget_window_outputs import make_single_year_macro_output
2329

2430

@@ -298,3 +304,43 @@ def child_result_without_state_tax(simulation_year: str) -> dict:
298304
)
299305
assert body["result"]["totals"]["stateTaxRevenueImpact"] == 0.0
300306
assert body["result"]["totals"]["federalTaxRevenueImpact"] == 300.0
307+
308+
309+
def test_budget_window_runner_resolves_persisted_child_handle_fallback(
310+
budget_window_semi_integration_client,
311+
):
312+
_, runtime = budget_window_semi_integration_client
313+
child_call = object()
314+
runtime.calls["child-2026"] = child_call
315+
316+
request = BudgetWindowBatchRequest.model_validate(
317+
{
318+
"country": "us",
319+
"region": "us",
320+
"scope": "macro",
321+
"reform": {},
322+
"start_year": "2026",
323+
"window_size": 1,
324+
"max_parallel": 1,
325+
}
326+
)
327+
context = BudgetWindowBatchContext(
328+
batch_job_id="parent-resume-123",
329+
request=request,
330+
resolved_version="1.500.0",
331+
resolved_app_name="policyengine-simulation-us1-500-0-uk2-66-0",
332+
bundle=PolicyEngineBundle(model_version="1.500.0"),
333+
raw_params=request.model_dump(mode="json"),
334+
)
335+
runner = scheduler_module.BudgetWindowBatchRunner(context)
336+
runner.state.child_jobs["2026"] = BatchChildJobStatus(
337+
job_id="child-2026",
338+
status="running",
339+
)
340+
341+
handle = runner.resolve_child_handle("2026")
342+
343+
assert handle.simulation_year == "2026"
344+
assert handle.job_id == "child-2026"
345+
assert handle.call is child_call
346+
assert runner.child_handles["2026"] is handle

projects/policyengine-api-simulation/tests/test_modal_scripts.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,55 @@ def test_fails_when_auth_required_but_gateway_auth_vars_missing(self):
439439
assert result.returncode != 0
440440
assert "GATEWAY_AUTH_REQUIRED is enabled" in result.stderr
441441

442+
def test_exports_us_and_uk_model_versions_to_integration_tests(self, tmp_path):
443+
"""Deploy-extracted model versions should reach the pytest settings."""
444+
uv_calls_log = tmp_path / "uv_calls.log"
445+
fake_bin = tmp_path / "bin"
446+
fake_bin.mkdir()
447+
fake_uv = fake_bin / "uv"
448+
fake_uv.write_text(
449+
'#!/bin/bash\n'
450+
'printf "%s|base=%s|us=%s|uk=%s\\n" "$*" '
451+
'"${simulation_integ_test_base_url:-}" '
452+
'"${simulation_integ_test_us_model_version:-}" '
453+
'"${simulation_integ_test_uk_model_version:-}" >> "$UV_CALLS_LOG"\n'
454+
)
455+
fake_uv.chmod(0o755)
456+
457+
env = os.environ.copy()
458+
env["PATH"] = f"{fake_bin}:{env['PATH']}"
459+
env["UV_CALLS_LOG"] = str(uv_calls_log)
460+
for key in (
461+
"GATEWAY_AUTH_REQUIRED",
462+
"GATEWAY_AUTH_ISSUER",
463+
"GATEWAY_AUTH_AUDIENCE",
464+
"GATEWAY_AUTH_CLIENT_ID",
465+
"GATEWAY_AUTH_CLIENT_SECRET",
466+
):
467+
env.pop(key, None)
468+
469+
result = subprocess.run(
470+
[
471+
"bash",
472+
str(self.script),
473+
"prod",
474+
"https://example.com",
475+
"1.690.7",
476+
"2.88.14",
477+
],
478+
capture_output=True,
479+
text=True,
480+
env=env,
481+
cwd=REPO_ROOT,
482+
)
483+
484+
assert result.returncode == 0, f"Script failed: {result.stderr}"
485+
log = uv_calls_log.read_text()
486+
assert "run pytest tests/simulation/ -v -m not beta_only" in log
487+
assert "base=https://example.com" in log
488+
assert "us=1.690.7" in log
489+
assert "uk=2.88.14" in log
490+
442491

443492
class TestAllScriptsHaveShebang:
444493
"""Verify all scripts have proper shebang and error handling."""

projects/policyengine-apis-integ/tests/simulation/conftest.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class Settings(BaseSettings):
3434
timeout_in_millis: int = 600_000 # 10 minutes for full simulations
3535
poll_interval_seconds: float = 5.0
3636
us_model_version: str = "1.562.3"
37+
uk_model_version: str = "2.88.14"
3738

3839
model_config = SettingsConfigDict(
3940
env_prefix="simulation_integ_test_",
@@ -61,6 +62,12 @@ def us_model_version() -> str:
6162
return settings.us_model_version
6263

6364

65+
@pytest.fixture()
66+
def uk_model_version() -> str:
67+
"""Return the UK model version for testing specific version scenarios."""
68+
return settings.uk_model_version
69+
70+
6471
@pytest.fixture()
6572
def poll_interval() -> float:
6673
"""Return poll interval in seconds."""

projects/policyengine-apis-integ/tests/simulation/test_calculate.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ def test_calculate_specific_model(
199199
@pytest.mark.beta_only
200200
def test_calculate_uk_model(
201201
client: Client | AuthenticatedClient,
202+
uk_model_version: str,
202203
max_wait_seconds: float,
203204
poll_interval: float,
204205
):
@@ -211,6 +212,7 @@ def test_calculate_uk_model(
211212
request = SimulationRequest.from_dict(
212213
{
213214
"country": "uk",
215+
"version": uk_model_version,
214216
"scope": "macro",
215217
"reform": {
216218
"gov.hmrc.income_tax.rates.uk[0].rate": {"2023-01-01.2100-12-31": 0.21}
@@ -226,6 +228,9 @@ def test_calculate_uk_model(
226228
assert isinstance(submit_response, JobSubmitResponse), (
227229
f"Unexpected response type: {type(submit_response)}"
228230
)
231+
assert submit_response.version == uk_model_version, (
232+
f"Version mismatch: expected {uk_model_version}, got {submit_response.version}"
233+
)
229234
job_id = submit_response.job_id
230235

231236
# When - poll for completion

0 commit comments

Comments
 (0)