diff --git a/.github/workflows/v2-integration-check.yml b/.github/workflows/v2-integration-check.yml index e6beed211..9ec8eac0f 100644 --- a/.github/workflows/v2-integration-check.yml +++ b/.github/workflows/v2-integration-check.yml @@ -77,8 +77,8 @@ jobs: run: uv run coverage run --branch -m pytest -q tests/integration/test_v2_catalog_installed.py env: RUN_V2_CATALOG_COMPATIBILITY: "1" - - name: Test v2 metadata publication, resource routes, the countries list and economy reports - run: uv run coverage run -a --branch -m pytest -q tests/integration/test_v2_catalog_publication.py tests/integration/test_v2_metadata_routes.py tests/integration/test_v2_country_routes.py tests/integration/test_v2_economy_reports.py + - name: Test v2 metadata publication, resource routes, the countries list and economy and household reports + run: uv run coverage run -a --branch -m pytest -q tests/integration/test_v2_catalog_publication.py tests/integration/test_v2_metadata_routes.py tests/integration/test_v2_country_routes.py tests/integration/test_v2_economy_reports.py tests/integration/test_v2_household_reports.py - name: Test v2 policy persistence and immediate v1 mirroring run: uv run coverage run -a --branch -m pytest -q tests/integration/test_v2_policy_persistence.py tests/integration/test_v1_policy_dual_write.py tests/integration/test_mysql_policy_dual_write.py tests/integration/test_v2_user_policy_mirroring.py tests/integration/test_v1_user_policy_dual_write.py - name: Test signed-in identity provisioning and user associations diff --git a/changelog.d/v2-household-reports.added.md b/changelog.d/v2-household-reports.added.md new file mode 100644 index 000000000..76fc09d71 --- /dev/null +++ b/changelog.d/v2-household-reports.added.md @@ -0,0 +1 @@ +Household reports at /v2/reports: a household stored through /v2/households under a baseline and an optional reform, calculated by the simulation service for the household's default year, for any country with household calculations, including a household-only one. diff --git a/docs/engineering/migration-contracts.md b/docs/engineering/migration-contracts.md index 7f56f8d13..cc4237a6e 100644 --- a/docs/engineering/migration-contracts.md +++ b/docs/engineering/migration-contracts.md @@ -7,8 +7,8 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi | Metric | Count | | --- | ---: | | route group count | 9 | -| workflow count | 18 | -| request count | 96 | +| workflow count | 19 | +| request count | 99 | | db entity count | 6 | | sim flow count | 3 | @@ -213,6 +213,17 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi | `POST` | `/v2/reports/{report_id}/runs?country_id=us` | 201 | `report` | none | `status`, `message`, `result.item.id`, `result.item.trigger`, `result.item.tax_benefit_model_version_id`, `result.item.policyengine_version`, `result.item.status` | | `GET` | `/v2/reports/{report_id}/runs?country_id=us` | 200 | `report` | none | `status`, `message`, `result.items` | +### `household_reports_v2` + +- Current contract: `typed_v2_resources` +- Future owner: Household reports on v2 + +| Method | Path | Status | Route group | Required headers | Stable response fields | +| --- | --- | ---: | --- | --- | --- | +| `POST` | `/v2/reports?country_id=uk` | 201 | `report` | none | `status`, `message`, `result.item.id`, `result.item.country_id`, `result.item.kind`, `result.item.year`, `result.item.household_id`, `result.item.baseline_simulation_id`, `result.item.reform_simulation_id`, `result.item.status`, `result.item.run` | +| `GET` | `/v2/reports/{report_id}?country_id=uk` | 200 | `report` | none | `status`, `message`, `result.item.id`, `result.item.kind`, `result.item.status`, `result.item.run.baseline.result`, `result.item.run.reform.result` | +| `POST` | `/v2/reports/{report_id}/runs?country_id=uk` | 201 | `report` | none | `status`, `message`, `result.item.kind`, `result.item.id`, `result.item.trigger`, `result.item.tax_benefit_model_version_id`, `result.item.policyengine_version`, `result.item.status` | + ### `metadata_resources_v2_preview` - Current contract: `typed_v2_resources` diff --git a/docs/generated/migration_contracts.json b/docs/generated/migration_contracts.json index a81086a9c..0f62ccbe2 100644 --- a/docs/generated/migration_contracts.json +++ b/docs/generated/migration_contracts.json @@ -1,10 +1,10 @@ { "metadata": { "db_entity_count": 6, - "request_count": 96, + "request_count": 99, "route_group_count": 9, "sim_flow_count": 3, - "workflow_count": 18 + "workflow_count": 19 }, "route_groups": [ { @@ -1471,6 +1471,66 @@ } ] }, + { + "current_contract": "typed_v2_resources", + "future_owner_pr": "Household reports on v2", + "name": "household_reports_v2", + "requests": [ + { + "expected_status": 201, + "headers": [], + "method": "POST", + "path": "/v2/reports?country_id=uk", + "route_group": "report", + "stable_response_fields": [ + "status", + "message", + "result.item.id", + "result.item.country_id", + "result.item.kind", + "result.item.year", + "result.item.household_id", + "result.item.baseline_simulation_id", + "result.item.reform_simulation_id", + "result.item.status", + "result.item.run" + ] + }, + { + "expected_status": 200, + "headers": [], + "method": "GET", + "path": "/v2/reports/{report_id}?country_id=uk", + "route_group": "report", + "stable_response_fields": [ + "status", + "message", + "result.item.id", + "result.item.kind", + "result.item.status", + "result.item.run.baseline.result", + "result.item.run.reform.result" + ] + }, + { + "expected_status": 201, + "headers": [], + "method": "POST", + "path": "/v2/reports/{report_id}/runs?country_id=uk", + "route_group": "report", + "stable_response_fields": [ + "status", + "message", + "result.item.kind", + "result.item.id", + "result.item.trigger", + "result.item.tax_benefit_model_version_id", + "result.item.policyengine_version", + "result.item.status" + ] + } + ] + }, { "current_contract": "typed_v2_resources", "future_owner_pr": "Later metadata read cutover and v2 route-prefix removal", diff --git a/policyengine_api/fastapi_routes/dependencies.py b/policyengine_api/fastapi_routes/dependencies.py index 9657599ef..3db2fe60b 100644 --- a/policyengine_api/fastapi_routes/dependencies.py +++ b/policyengine_api/fastapi_routes/dependencies.py @@ -30,7 +30,13 @@ SimulationReferenceChecker, ) from policyengine_api.services.v2.countries.types import CountryRead - from policyengine_api.services.v2.economy_reports.types import EconomyReportFields + from policyengine_api.services.v2.economy_reports.types import ( + EconomyReportFields, + EconomyReportListing, + ) + from policyengine_api.services.v2.household_reports.types import ( + HouseholdReportSimulation, + ) from policyengine_api.services.v2.households.types import ( HouseholdPage, HouseholdRead, @@ -62,6 +68,9 @@ PolicyPage, PolicyRead, ) + from policyengine_api.services.v2.report_kinds.types import ( + HouseholdReportOperations as V2HouseholdReportResourceService, + ) from policyengine_api.services.v2.simulation_entrypoint.types import ( EconomyReportRecord, EconomyReportRunRecord, @@ -382,6 +391,12 @@ def list_reports( """Return the country's reports among ``report_ids``, in order, once each.""" ... + def find_reports( + self, *, country_id: str, report_ids: Sequence[UUID] + ) -> EconomyReportListing: + """List the country's reports among ``report_ids``, naming the unreadable.""" + ... + def list_report_runs( self, *, country_id: str, report_id: UUID ) -> tuple[EconomyReportRunRecord, ...]: @@ -901,6 +916,57 @@ def _default_v2_economy_report_service_factory() -> V2EconomyReportResourceServi ) +def _default_household_report_simulation() -> HouseholdReportSimulation: + """Return the process's adapter for the entrypoint's household report routes. + + Raise ``SimulationServiceUnavailableError`` when the generated client is + not installed or has no household report routes (a client generated + before them, whose import fails with ``ImportError``), or the entrypoint + URL or gateway auth is unusable. + """ + try: + from policyengine_api.libs.simulation_household_reports import ( + shared_household_reports, + ) + except ImportError as error: + if (error.name or "").split(".")[0] != SIMULATION_CLIENT_PACKAGE: + raise + msg = ( + "the generated simulation client (policyengine-api-simulation-client) " + "is not installed or has no household report routes" + ) + raise SimulationServiceUnavailableError(msg) from error + return shared_household_reports() + + +def _default_v2_household_report_service_factory() -> V2HouseholdReportResourceService: + """Build the household report service; the adapter waits for its first call. + + Refusals the Public API decides itself, such as a household-only + country's missing household, then answer without the simulation client. + """ + from policyengine_api.data.v2.database import ( + get_v2_session_factory, + ) + from policyengine_api.services.v2.household_reports.database_session import ( + HouseholdReportDatabaseSession, + ) + from policyengine_api.services.v2.household_reports.services import ( + DeferredHouseholdReportSimulation, + V2HouseholdReportService, + ) + + return V2HouseholdReportService( + HouseholdReportDatabaseSession(get_v2_session_factory()), + households=_default_v2_household_service_factory(), + policies=_default_v2_policy_service_factory(), + simulation=DeferredHouseholdReportSimulation( + _default_household_report_simulation + ), + current_policyengine_version=_running_policyengine_version(), + ) + + def _default_v2_policy_service_factory() -> V2PolicyResourceService: from policyengine_api.data.v2.database import ( get_v2_session_factory, @@ -987,11 +1053,19 @@ def _default_legacy_reference_checker() -> LegacyReferenceChecker: def _default_simulation_reference_checker() -> SimulationReferenceChecker: """Defer the simulation client until a request checks a v2 reference.""" + from policyengine_api.services.v2.household_reports.services import ( + DeferredHouseholdReportSimulation, + ) from policyengine_api.services.v2.simulation_refs.services import ( SimulationReferenceService, ) - return SimulationReferenceService(_deferred_economy_report_simulation()) + return SimulationReferenceService( + _deferred_economy_report_simulation(), + household_reports=DeferredHouseholdReportSimulation( + _default_household_report_simulation + ), + ) def _default_v2_user_policy_service_factory() -> V2UserPolicyResourceService: @@ -1091,6 +1165,9 @@ class NativeRouteDependencies: v2_economy_report_service_factory: ( Callable[[], V2EconomyReportResourceService] | None ) = None + v2_household_report_service_factory: ( + Callable[[], V2HouseholdReportResourceService] | None + ) = None v2_policy_service_factory: Callable[[], V2PolicyResourceService] | None = None v2_household_service_factory: Callable[[], V2HouseholdResourceService] | None = None v2_user_policy_service_factory: Callable[[], V2UserPolicyResourceService] | None = ( @@ -1125,6 +1202,9 @@ def defaults(cls) -> NativeRouteDependencies: v2_economy_report_service_factory=( _default_v2_economy_report_service_factory ), + v2_household_report_service_factory=( + _default_v2_household_report_service_factory + ), v2_policy_service_factory=_default_v2_policy_service_factory, v2_household_service_factory=_default_v2_household_service_factory, v2_user_policy_service_factory=(_default_v2_user_policy_service_factory), diff --git a/policyengine_api/fastapi_routes/v2/reports/household_response_models.py b/policyengine_api/fastapi_routes/v2/reports/household_response_models.py new file mode 100644 index 000000000..800fc2bc1 --- /dev/null +++ b/policyengine_api/fastapi_routes/v2/reports/household_response_models.py @@ -0,0 +1,144 @@ +"""Strict HTTP response models for v2 household reports.""" + +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING, Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, JsonValue + +from policyengine_api.services.v2.simulation_entrypoint.types import ReportStatus + +if TYPE_CHECKING: + from policyengine_api.services.v2.household_reports.types import ( + HouseholdReportRecord, + HouseholdReportRunRecord, + ) + + +class StrictHouseholdReportAPIModel(BaseModel): + """Strict response base that converts the service's dataclass records.""" + + model_config = ConfigDict( + extra="forbid", + allow_inf_nan=False, + from_attributes=True, + ) + + +class HouseholdReportCalculationErrorItem(StrictHouseholdReportAPIModel): + """Why a household calculation failed. + + ``code`` is ``household_calculation_failed`` when the household function + refused the inputs, with its error in ``detail``; ``worker_error``, + ``worker_timeout`` or ``dispatch_abandoned`` otherwise. + """ + + code: str + message: str + detail: dict[str, JsonValue] | None + + +class HouseholdReportCalculationResultItem(StrictHouseholdReportAPIModel): + """The household with each requested value filled in, in its own key order. + + ``spm_config`` is the SPM selection applied and ``spm_provenance`` its + receipt, both null for a country without SPM. + """ + + household: dict[str, JsonValue] + warnings: list[str] + spm_config: dict[str, JsonValue] | None + spm_provenance: dict[str, JsonValue] | None + + +class HouseholdReportCalculationItem(StrictHouseholdReportAPIModel): + """One calculation of the household under one policy (a simulation run). + + A null ``policy_id`` means current law. ``status`` is ``pending``, + ``dispatching``, ``running``, ``succeeded`` or ``failed``; ``attempt`` + counts forced recalculations. ``result`` is set once it succeeded and + ``error`` once it failed. + """ + + id: UUID + simulation_id: UUID + policy_id: UUID | None + status: str + attempt: int + trigger: str + error: HouseholdReportCalculationErrorItem | None + result: HouseholdReportCalculationResultItem | None + dispatched_at: datetime | None + completed_at: datetime | None + created_at: datetime + + +class HouseholdReportRunSummaryItem(StrictHouseholdReportAPIModel): + """One calculation of a household report, without its calculations. + + ``trigger`` is ``initial`` for the run a submission starts and ``manual`` + for a re-run. ``tax_benefit_model_version_id`` and ``policyengine_version`` + name the catalog version it ran on. + """ + + kind: Literal["household"] = "household" + id: UUID + report_id: UUID + trigger: str + tax_benefit_model_version_id: UUID + policyengine_version: str | None + year: int + status: ReportStatus + + +class HouseholdReportRunItem(HouseholdReportRunSummaryItem): + """One calculation of a household report: the baseline's and the reform's. + + ``reform`` is null for a report of the baseline alone. + """ + + baseline: HouseholdReportCalculationItem + reform: HouseholdReportCalculationItem | None + + @classmethod + def from_record(cls, record: HouseholdReportRunRecord) -> HouseholdReportRunItem: + """Build the response item from the service's report run record.""" + return cls.model_validate(record) + + +class HouseholdReportSummaryItem(StrictHouseholdReportAPIModel): + """A household report: a stored household under a baseline and a reform. + + The report is for the household's ``default_year``. ``status`` is the + newest run's, or ``not_run`` before the first run, when ``household_id`` is + not known either. ``reform_simulation_id`` is null for a report of the + baseline alone. + """ + + id: UUID + country_id: str + kind: Literal["household"] = "household" + year: int + household_id: UUID | None + baseline_simulation_id: UUID + reform_simulation_id: UUID | None + status: ReportStatus + run: HouseholdReportRunSummaryItem | None + + @classmethod + def from_record(cls, record: HouseholdReportRecord) -> HouseholdReportSummaryItem: + """Build the summary from the service's record, without its calculations.""" + return cls.model_validate(record) + + +class HouseholdReportItem(HouseholdReportSummaryItem): + """A household report with its newest run and that run's calculations.""" + + run: HouseholdReportRunItem | None + + @classmethod + def from_record(cls, record: HouseholdReportRecord) -> HouseholdReportItem: + """Build the response item from the service's report record.""" + return cls.model_validate(record) diff --git a/policyengine_api/fastapi_routes/v2/reports/request_models.py b/policyengine_api/fastapi_routes/v2/reports/request_models.py index 3fe27d938..57dde3783 100644 --- a/policyengine_api/fastapi_routes/v2/reports/request_models.py +++ b/policyengine_api/fastapi_routes/v2/reports/request_models.py @@ -1,10 +1,17 @@ -"""Strict HTTP request models for v2 economy reports.""" +"""Strict HTTP request models for v2 economy and household reports.""" + +from typing import Annotated + +from pydantic import Field from policyengine_api.query_parameters import CountryId from policyengine_api.services.v2.economy_reports.types import ( EconomyReportFields, EconomyReportRunFields, ) +from policyengine_api.services.v2.household_reports.types import ( + HouseholdReportFields, +) class EconomyReportCreateRequest(EconomyReportFields): @@ -17,8 +24,32 @@ class EconomyReportCreateRequest(EconomyReportFields): country_id: CountryId +class HouseholdReportCreateRequest(HouseholdReportFields): + """A new household report; ``country_id`` must match the query's. + + ``baseline`` and ``reform`` each name a v2 policy of the country, and an + empty object means current law; ``reform`` is null for a report of the + baseline alone. It is calculated for the household's ``default_year``; + with ``add_missing_variables``, each calculation also adds every yearly, + monthly and eternal variable the household lacks, as ``/calculate-full`` + does. A re-run keeps the choice made when the report was created. + """ + + country_id: CountryId + + +ReportCreateRequest = Annotated[ + EconomyReportCreateRequest | HouseholdReportCreateRequest, + Field(discriminator="kind"), +] + + class EconomyReportRunRequest(EconomyReportRunFields): - """A re-run on the current catalog version; ``{}`` reuses existing runs. + """A re-run of an economy or household report on the current catalog version. - With ``force``, both simulations are calculated again as new attempts. + ``{}`` reuses existing runs: an economy report's simulation runs for that + version and year, or a household report's calculations that are + unfinished or succeeded within the last 28 days. With ``force``, each of + the report's simulations (one for a household report of the baseline + alone) is calculated again as a new attempt. """ diff --git a/policyengine_api/fastapi_routes/v2/reports/response_models.py b/policyengine_api/fastapi_routes/v2/reports/response_models.py index 398f76a05..1be9eeab6 100644 --- a/policyengine_api/fastapi_routes/v2/reports/response_models.py +++ b/policyengine_api/fastapi_routes/v2/reports/response_models.py @@ -1,14 +1,22 @@ -"""Strict HTTP response models for v2 economy reports.""" +"""Strict HTTP response models for v2 economy reports, and both kinds' envelopes. + +Household reports' own items are in ``household_response_models``. +""" from __future__ import annotations from datetime import datetime -from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar +from typing import TYPE_CHECKING, Annotated, Any, Generic, Literal, TypeVar from uuid import UUID -from pydantic import BaseModel, ConfigDict, JsonValue +from pydantic import BaseModel, ConfigDict, Field, JsonValue from policyengine_api.fastapi_routes.v2.errors import V2ErrorResponse +from policyengine_api.fastapi_routes.v2.reports.household_response_models import ( + HouseholdReportItem, + HouseholdReportRunItem, + HouseholdReportSummaryItem, +) from policyengine_api.services.v2.simulation_entrypoint.types import ReportStatus if TYPE_CHECKING: @@ -91,6 +99,7 @@ class ReportRunItem(StrictReportAPIModel): ``simulating`` or ``outputs``. """ + kind: Literal["economy"] = "economy" id: UUID report_id: UUID trigger: str @@ -151,22 +160,41 @@ def from_record(cls, record: EconomyReportRecord) -> ReportItem: return cls.model_validate(record) +ReportDetailItem = Annotated[ + ReportItem | HouseholdReportItem, Field(discriminator="kind") +] + + class ReportDetailResult(StrictReportAPIModel): - """Result holding one report in ``item``.""" + """Result holding one report, of either kind, in ``item``.""" - item: ReportItem + item: ReportDetailItem + + +ReportSummary = Annotated[ + ReportSummaryItem | HouseholdReportSummaryItem, Field(discriminator="kind") +] class ReportListResult(StrictReportAPIModel): - """The listed reports in ``items``, in the order asked, without results.""" + """The listed reports of both kinds in ``items``, in the order asked. - items: list[ReportSummaryItem] + Economy reports are listed without their results and household reports + without their calculations. + """ + + items: list[ReportSummary] + + +ReportRunDetailItem = Annotated[ + ReportRunItem | HouseholdReportRunItem, Field(discriminator="kind") +] class ReportRunDetailResult(StrictReportAPIModel): - """Result holding one report run in ``item``.""" + """Result holding one report run, of either kind, in ``item``.""" - item: ReportRunItem + item: ReportRunDetailItem class ReportRunListResult(StrictReportAPIModel): @@ -215,20 +243,32 @@ def _error(description: str) -> dict[str, Any]: # Submitting or re-running a report can also be refused for the report itself. REPORT_REFUSAL_RESPONSE: dict[int | str, dict[str, Any]] = { 422: _error( - "The request does not match the schema, the country does not have " - "economy reports (such as a household-only country), or the simulation " + "The request does not match the schema; the country does not have " + "economy reports (such as a household-only country) or household " + "calculations; the household has no default year; or the simulation " "service cannot run the report (for example, no such region or " - "dataset in the current catalog version); the message says which." + "dataset in the current catalog version, or a policy changing a " + "parameter that version lacks); the message says which." ), } REPORT_NOT_FOUND_RESPONSE: dict[int | str, dict[str, Any]] = { 404: _error("No report has this ID or v1 number in this country."), } +RUNS_NOT_FOUND_RESPONSE: dict[int | str, dict[str, Any]] = { + 404: _error( + "No economy report has this ID in this country. A household report " + "does not list its runs; its newest run is in the report itself." + ), +} REPORT_BODY_RESPONSE: dict[int | str, dict[str, Any]] = { 413: _error("The request body exceeds 16 KiB."), } SUBMIT_RESPONSES: dict[int | str, dict[str, Any]] = { 400: _error("The body and query name different countries."), + 404: _error( + "A household report's household or one of its policies does not exist " + "in this country." + ), **REPORT_BODY_RESPONSE, **REPORT_REFUSAL_RESPONSE, } diff --git a/policyengine_api/fastapi_routes/v2/reports/routes.py b/policyengine_api/fastapi_routes/v2/reports/routes.py index 639b1c239..1b883c9ca 100644 --- a/policyengine_api/fastapi_routes/v2/reports/routes.py +++ b/policyengine_api/fastapi_routes/v2/reports/routes.py @@ -1,7 +1,9 @@ -"""Native FastAPI routes for v2 economy reports. +"""Native FastAPI routes for v2 reports: economy reports and household reports. Reports are calculated and stored by the Simulation Entrypoint; these routes need no user credential. A user keeps a report through ``/v2/user-reports``. +A submission names its kind; ``V2ReportService`` sends each operation to the +economy or household report service. """ from __future__ import annotations @@ -18,6 +20,7 @@ from policyengine_api.data.v2.settings import V2ConfigurationError from policyengine_api.fastapi_routes.dependencies import ( _default_v2_economy_report_service_factory, + _default_v2_household_report_service_factory, ) from policyengine_api.fastapi_routes.query_parameters import query_dependency from policyengine_api.fastapi_routes.v2.errors import ( @@ -26,15 +29,20 @@ run_v2_operation, v2_error_response, ) +from policyengine_api.fastapi_routes.v2.reports.household_response_models import ( + HouseholdReportItem, + HouseholdReportRunItem, + HouseholdReportSummaryItem, +) from policyengine_api.fastapi_routes.v2.reports.request_models import ( - EconomyReportCreateRequest, EconomyReportRunRequest, + ReportCreateRequest, ) from policyengine_api.fastapi_routes.v2.reports.response_models import ( READ_RESPONSES, REPORT_ERROR_RESPONSES, - REPORT_NOT_FOUND_RESPONSE, RUN_RESPONSES, + RUNS_NOT_FOUND_RESPONSE, SUBMIT_RESPONSES, ReportDetailResponse, ReportDetailResult, @@ -59,6 +67,19 @@ EconomyReportNotFoundError, EconomyReportsUnsupportedError, ) +from policyengine_api.services.v2.household_reports.types import ( + HouseholdReportRecord, + HouseholdReportRunRecord, +) +from policyengine_api.services.v2.household_reports.validators import ( + HouseholdReportNotFoundError, + HouseholdReportsUnsupportedError, + HouseholdReportYearError, +) +from policyengine_api.services.v2.households.validators import HouseholdNotFoundError +from policyengine_api.services.v2.policies.validators import PolicyNotFoundError +from policyengine_api.services.v2.report_kinds.services import V2ReportService +from policyengine_api.services.v2.report_kinds.validators import ReportNotFoundError from policyengine_api.services.v2.simulation_entrypoint.types import ( SimulationRecordUnreadableError, SimulationRequestRefusedError, @@ -70,7 +91,6 @@ from policyengine_api.fastapi_routes.dependencies import ( NativeRouteDependencies, - V2EconomyReportResourceService, ) from policyengine_api.services.v2.simulation_entrypoint.types import ( EconomyReportRecord, @@ -107,16 +127,37 @@ EconomyReportsUnsupportedError, SimulationRequestRefusedError, EconomyReportNotFoundError, + HouseholdReportsUnsupportedError, + HouseholdReportYearError, + HouseholdReportNotFoundError, + HouseholdNotFoundError, + PolicyNotFoundError, + ReportNotFoundError, ) REPORT_ERROR_MAPPINGS = ( - V2ErrorMapping((EconomyReportNotFoundError,), 404), + V2ErrorMapping( + ( + ReportNotFoundError, + EconomyReportNotFoundError, + HouseholdReportNotFoundError, + HouseholdNotFoundError, + PolicyNotFoundError, + ), + 404, + ), V2ErrorMapping( (SimulationRecordUnreadableError,), 502, "The simulation service cannot read back this report's result", ), V2ErrorMapping( - (EconomyReportsUnsupportedError, SimulationRequestRefusedError), 422 + ( + EconomyReportsUnsupportedError, + HouseholdReportsUnsupportedError, + HouseholdReportYearError, + SimulationRequestRefusedError, + ), + 422, ), V2ErrorMapping( (SimulationServiceUnavailableError,), @@ -171,30 +212,55 @@ def logged() -> OperationT: def _service_factory( dependencies: NativeRouteDependencies, -) -> Callable[[], V2EconomyReportResourceService]: - if dependencies.v2_economy_report_service_factory is not None: - return dependencies.v2_economy_report_service_factory - return _default_v2_economy_report_service_factory +) -> Callable[[], V2ReportService]: + economy_factory = ( + dependencies.v2_economy_report_service_factory + or _default_v2_economy_report_service_factory + ) + household_factory = ( + dependencies.v2_household_report_service_factory + or _default_v2_household_report_service_factory + ) + return lambda: V2ReportService( + economy_reports=economy_factory, household_reports=household_factory + ) -def _report_detail(report: EconomyReportRecord) -> ReportDetailResponse: - return ReportDetailResponse( - result=ReportDetailResult(item=ReportItem.from_record(report)) +def _report_detail( + report: EconomyReportRecord | HouseholdReportRecord, +) -> ReportDetailResponse: + item = ( + HouseholdReportItem.from_record(report) + if isinstance(report, HouseholdReportRecord) + else ReportItem.from_record(report) ) + return ReportDetailResponse(result=ReportDetailResult(item=item)) -def _report_list(reports: tuple[EconomyReportRecord, ...]) -> ReportListResponse: +def _report_list( + reports: tuple[EconomyReportRecord | HouseholdReportRecord, ...], +) -> ReportListResponse: return ReportListResponse( result=ReportListResult( - items=[ReportSummaryItem.from_record(report) for report in reports] + items=[ + HouseholdReportSummaryItem.from_record(report) + if isinstance(report, HouseholdReportRecord) + else ReportSummaryItem.from_record(report) + for report in reports + ] ) ) -def _report_run_detail(report_run: EconomyReportRunRecord) -> ReportRunDetailResponse: - return ReportRunDetailResponse( - result=ReportRunDetailResult(item=ReportRunItem.from_record(report_run)) +def _report_run_detail( + report_run: EconomyReportRunRecord | HouseholdReportRunRecord, +) -> ReportRunDetailResponse: + item = ( + HouseholdReportRunItem.from_record(report_run) + if isinstance(report_run, HouseholdReportRunRecord) + else ReportRunItem.from_record(report_run) ) + return ReportRunDetailResponse(result=ReportRunDetailResult(item=item)) def _report_run_list( @@ -210,7 +276,7 @@ def _report_run_list( def build_v2_report_router( dependencies: NativeRouteDependencies, ) -> APIRouter: - """Build the economy report routes without opening a connection.""" + """Build the report routes without opening a connection.""" router = APIRouter(prefix="/v2", responses=REPORT_ERROR_RESPONSES) service_factory = _service_factory(dependencies) @@ -219,17 +285,24 @@ def build_v2_report_router( response_model=ReportDetailResponse, status_code=201, responses=SUBMIT_RESPONSES, - summary="Submit an economy report", + summary="Submit an economy or household report", description=( - "Creates the baseline and reform simulations, the report and its " - "first run in the simulation service, on the country's current " - "catalog version, and starts the run. Read the report to follow it. " - "A country without economy reports, such as a household-only one, " - "is refused with 422 before anything is created." + "Creates the report, its simulations and its first run in the " + "simulation service, on the country's current catalog version, and " + "starts the run. Read the report to follow it. An economy report " + "(kind economy) compares two policies across a region; a country " + "without economy reports, such as a household-only one, is refused " + "with 422 before anything is created. A household report (kind " + "household) calculates a household stored through /v2/households " + "under a baseline and an optional reform, for the household's " + "default year; it needs the country's household calculations, which " + "a household-only country has, and a household and policies of the " + "country (404 otherwise) with a default year (422 otherwise), all " + "checked before anything is created." ), ) def submit_report( - body: EconomyReportCreateRequest, + body: ReportCreateRequest, query: ReportCreateQueryDependency, _size: Annotated[None, Depends(enforce_report_request_size)], ) -> ReportDetailResponse | JSONResponse: @@ -246,13 +319,14 @@ def submit_report( @router.get( "/reports", response_model=ReportListResponse, - summary="List chosen economy reports", + summary="List chosen reports", description=( - "Lists the country's reports among the report_id values (at most " - "25), in that order and once each, with their status and newest run " - "but without results. Unknown reports and other countries' are left " - "out. Listing does not start the first run of a report that has " - "none; reading it does." + "Lists the country's reports of both kinds among the report_id " + "values (at most 25), in that order and once each, with their " + "status and newest run but without economy results or household " + "calculations. Unknown reports and other countries' are left out. " + "Listing does not start the first run of an economy report that " + "has none; reading it does." ), ) def list_reports( @@ -293,14 +367,15 @@ def get_report_by_legacy_number( "/reports/{report_id}", response_model=ReportDetailResponse, responses=READ_RESPONSES, - summary="Read an economy report", + summary="Read an economy or household report", description=( "Returns the report, its newest run and, once that run succeeded, its " - "result. A report with no run, such as one migrated from v1, is " - "calculated the first time it is read: the read starts a run with " - "the system trigger on the current catalog version and answers with " - "it. If the simulation service cannot run it, the report is " - "returned not run." + "result: an economy report's result, or a household report's " + "calculated households in the run's baseline and reform. An economy " + "report with no run, such as one migrated from v1, is calculated the " + "first time it is read: the read starts a run with the system " + "trigger on the current catalog version and answers with it. If the " + "simulation service cannot run it, the report is returned not run." ), ) def get_report( @@ -320,13 +395,18 @@ def get_report( response_model=ReportRunDetailResponse, status_code=201, responses=RUN_RESPONSES, - summary="Run an economy report again", + summary="Run an economy or household report again", description=( "Runs the report on the country's current catalog version with the " - "manual trigger. Simulation runs that already exist for that version " - "and year are reused unless force is set, which calculates both " - "simulations again. A country without economy reports is refused " - "with 422 before the simulation service is asked." + "manual trigger, and answers with the new run. Simulation runs that " + "already exist for that version and year are reused unless force is " + "set, which calculates both simulations again; a household report " + "reuses calculations that are unfinished or succeeded within the " + "last 28 days. A country without economy reports, such as a " + "household-only one, can run only its household reports again; its " + "other report IDs are refused with 422, and a country without " + "household calculations cannot run household reports again (422), " + "before the simulation service is asked to run them." ), ) def run_report( @@ -346,11 +426,12 @@ def run_report( @router.get( "/reports/{report_id}/runs", response_model=ReportRunListResponse, - responses=REPORT_NOT_FOUND_RESPONSE, + responses=RUNS_NOT_FOUND_RESPONSE, summary="List an economy report's runs", description=( "Every run of the report, newest first, each with the catalog version " - "and PolicyEngine.py version it ran on." + "and PolicyEngine.py version it ran on. Only economy reports list " + "their runs; a household report's newest run is in the report." ), ) def list_report_runs( diff --git a/policyengine_api/libs/simulation_economy_reports.py b/policyengine_api/libs/simulation_economy_reports.py index 193ff68da..2d0f52d89 100644 --- a/policyengine_api/libs/simulation_economy_reports.py +++ b/policyengine_api/libs/simulation_economy_reports.py @@ -1,7 +1,8 @@ """The Simulation Entrypoint's economy report routes, through its generated client. -This is the only module that imports ``policyengine_api_simulation_client``, -the client generated from the Simulation Entrypoint's OpenAPI document. +This module and ``policyengine_api.libs.simulation_household_reports`` are +the only modules that import ``policyengine_api_simulation_client``, the +client generated from the Simulation Entrypoint's OpenAPI document. Everything else uses ``EconomyReportSimulation``, which this module implements, so the rest of the code and its tests do not need the client. Nothing imports this module at module level, so the application starts diff --git a/policyengine_api/libs/simulation_household_reports.py b/policyengine_api/libs/simulation_household_reports.py new file mode 100644 index 000000000..585fa47c3 --- /dev/null +++ b/policyengine_api/libs/simulation_household_reports.py @@ -0,0 +1,419 @@ +"""The Simulation Entrypoint's household report routes, through its generated client. + +This module and ``policyengine_api.libs.simulation_economy_reports`` are the +only modules that import ``policyengine_api_simulation_client``, the client +generated from the Simulation Entrypoint's OpenAPI document. Everything else +uses ``HouseholdReportSimulation``, which this module implements, so the rest +of the code and its tests do not need the client. Nothing imports this +module at module level, so the application starts without the client +installed and household reports then answer 503. + +Requests go to ``SIMULATION_ENTRYPOINT_URL``, the Cloud Run Simulation +Entrypoint, the only one with these routes, with the gateway bearer token +(``simulation:submit`` to submit or run, ``simulation:read`` to read). + +The entrypoint names the Public API's stored household and policies by ID and +reads them itself. Its declared answers become the protocol's errors: + +- 404 with the entrypoint's own detail: no such report. A 404 whose detail is + exactly "Not Found" is FastAPI's answer for a route the entrypoint does not + serve, such as a revision without these routes, so it means the entrypoint + cannot be used. +- 422: the caller's data it cannot calculate (a parameter the catalog version + lacks, a stored policy value or household the household functions refuse), + with its reason, or the request fields it refused. +- 400: a household, policy or catalog version it does not have, a household + without a default year, or no household function for the PolicyEngine.py + version. The Public API checks the household, the policies and the default + year itself before sending; the catalog version and the household function + are those of the version the Public API runs, which the entrypoint must + serve. So a 400 means the two services disagree, and the entrypoint cannot + be used for this request. +- 503, any other answer, an answer that does not parse, no answer or a + gateway token that cannot be fetched: the entrypoint cannot be used now. +""" + +from __future__ import annotations + +from datetime import datetime +from functools import cache +from typing import TYPE_CHECKING, Any, NoReturn, Protocol, TypeVar +from uuid import UUID + +import httpx +from policyengine_api_simulation_client import AuthenticatedClient +from policyengine_api_simulation_client.api.default import ( + get_household_report, + run_household_report, + submit_household_report, +) +from policyengine_api_simulation_client.models import ( + HouseholdCalculationOptions, + HouseholdRefusalResponse, + HouseholdReportResponse, + HouseholdReportRunSubmission, + HouseholdReportSubmissionCountry, + HouseholdRequestFieldError, + HouseholdRouteError, +) +from policyengine_api_simulation_client.models import ( + HouseholdPolicyChoice as GeneratedPolicyChoice, +) +from policyengine_api_simulation_client.models import ( + HouseholdReportSubmission as GeneratedSubmission, +) +from policyengine_api_simulation_client.models import ( + RunTrigger as GeneratedRunTrigger, +) +from policyengine_observability import instrument_httpx + +from policyengine_api.libs.gateway_auth import ( + GatewayAuthError, + gateway_bearer_auth_from_environment, +) +from policyengine_api.libs.simulation_http import ( + attach_request_identifiers, + required_base_url, +) +from policyengine_api.observability import get_runtime +from policyengine_api.services.v2.household_reports.types import ( + HouseholdCalculationError, + HouseholdCalculationOutput, + HouseholdCalculationRecord, + HouseholdReportRecord, + HouseholdReportRunRecord, +) +from policyengine_api.services.v2.simulation_entrypoint.types import ( + ReportStatus, + SimulationRecordNotFoundError, + SimulationRequestRefusedError, + SimulationServiceUnavailableError, +) + +if TYPE_CHECKING: + from collections.abc import Callable + + from policyengine_api_simulation_client.models import ( + HouseholdCalculationResponse, + ) + from policyengine_api_simulation_client.types import Response + + from policyengine_api.services.v2.household_reports.types import ( + HouseholdPolicyChoice, + HouseholdReportSubmission, + ) + from policyengine_api.services.v2.simulation_entrypoint.types import ( + RunTrigger, + ) + +SIMULATION_ENTRYPOINT_URL_ENV = "SIMULATION_ENTRYPOINT_URL" +# FastAPI's 404 detail for a route the application does not serve. +UNSERVED_ROUTE_DETAIL = "Not Found" +REQUEST_TIMEOUT_SECONDS = 30.0 +# A run claimed but not yet started on Modal is still waiting to run. +REPORT_STATUSES = { + "pending": ReportStatus.PENDING, + "dispatching": ReportStatus.PENDING, + "running": ReportStatus.RUNNING, + "succeeded": ReportStatus.SUCCEEDED, + "failed": ReportStatus.FAILED, +} +ParsedT = TypeVar("ParsedT") + + +def _raise_unusable(detail: str) -> NoReturn: + msg = f"the Simulation Entrypoint cannot serve household reports: {detail}" + raise SimulationServiceUnavailableError(msg) + + +def _refusal_reason(refusal: HouseholdRefusalResponse) -> str: + """Return a 422's reason: the entrypoint's text, or each refused field. + + A field's location is given without the request's ``body`` prefix. A + refusal of another shape means the entrypoint cannot be used. + """ + detail: object = refusal.detail + if isinstance(detail, str): + return detail + if not isinstance(detail, list) or not all( + isinstance(field_error, HouseholdRequestFieldError) for field_error in detail + ): + _raise_unusable("its refusal could not be read") + reasons = [] + for field_error in detail: + location = [str(part) for part in field_error.loc] + if location[:1] == ["body"]: + location = location[1:] + reasons.append(f"{'.'.join(location)}: {field_error.msg}") + return "; ".join(reasons) or "the request is invalid" + + +def _raise_declared_error(status_code: int, answer: object) -> NoReturn: + """Raise the protocol's error for one of the entrypoint's declared answers.""" + if isinstance(answer, HouseholdRefusalResponse): + raise SimulationRequestRefusedError(_refusal_reason(answer)) + if ( + isinstance(answer, HouseholdRouteError) + and status_code == httpx.codes.NOT_FOUND + and answer.detail != UNSERVED_ROUTE_DETAIL + ): + raise SimulationRecordNotFoundError(answer.detail) + detail = answer.detail[:200] if isinstance(answer, HouseholdRouteError) else "" + _raise_unusable(f"it answered HTTP {status_code} {detail}".rstrip()) + + +def _parsed(call: Callable[[], Response[Any]], expected: type[ParsedT]) -> ParsedT: + """Run one generated route call and return its parsed success answer.""" + try: + response = call() + except httpx.HTTPError as error: + msg = f"the Simulation Entrypoint could not be reached: {type(error).__name__}" + raise SimulationServiceUnavailableError(msg) from error + except GatewayAuthError as error: + msg = f"the gateway token for the Simulation Entrypoint is unavailable: {error}" + raise SimulationServiceUnavailableError(msg) from error + except (KeyError, TypeError, ValueError, AttributeError) as error: + msg = ( + "the Simulation Entrypoint's answer could not be read: " + f"{type(error).__name__}" + ) + raise SimulationServiceUnavailableError(msg) from error + if isinstance(response.parsed, expected): + return response.parsed + _raise_declared_error(int(response.status_code), response.parsed) + + +class _GeneratedObject(Protocol): + """A generated model of a free-form JSON object.""" + + def to_dict(self) -> dict[str, Any]: + """Return the object as decoded, in its key order.""" + ... + + +def _optional_object(value: _GeneratedObject | None) -> dict[str, Any] | None: + return None if value is None else value.to_dict() + + +CheckedT = TypeVar("CheckedT") + + +def _checked(value: object, expected: type[CheckedT]) -> CheckedT: + """Return ``value`` if it has the type the generated model declares for it. + + The generated models copy some fields as they came, such as a nullable + UUID or date-time, or a list of text, so the record checks them; raise + TypeError otherwise. + """ + if not isinstance(value, expected) or (expected is int and isinstance(value, bool)): + msg = f"expected {expected.__name__}, got {type(value).__name__}" + raise TypeError(msg) + return value + + +def _optional(value: object, expected: type[CheckedT]) -> CheckedT | None: + return None if value is None else _checked(value, expected) + + +def _warnings(warnings: object) -> tuple[str, ...]: + return tuple(_checked(warning, str) for warning in _checked(warnings, list)) + + +def _calculation( + calculation: HouseholdCalculationResponse, +) -> HouseholdCalculationRecord: + result, failure = calculation.result, calculation.error + return HouseholdCalculationRecord( + id=_checked(calculation.calculation_id, UUID), + simulation_id=_checked(calculation.simulation_id, UUID), + policy_id=_optional(calculation.policy_id, UUID), + status=calculation.status.value, + attempt=_checked(calculation.attempt, int), + trigger=calculation.trigger.value, + error=( + None + if failure is None + else HouseholdCalculationError( + code=_checked(failure.code, str), + message=_checked(failure.message, str), + detail=_optional_object(failure.detail), + ) + ), + result=( + None + if result is None + else HouseholdCalculationOutput( + household=result.household.to_dict(), + warnings=_warnings(result.warnings), + spm_config=_optional_object(result.spm_config), + spm_provenance=_optional_object(result.spm_provenance), + ) + ), + dispatched_at=_optional(calculation.dispatched_at, datetime), + completed_at=_optional(calculation.completed_at, datetime), + created_at=_checked(calculation.created_at, datetime), + ) + + +def _report_run(report: HouseholdReportResponse) -> HouseholdReportRunRecord | None: + """Return the report's run, or None before its first run.""" + if ( + report.report_run_id is None + or report.catalog_version_id is None + or report.trigger is None + or report.status is None + or report.baseline is None + ): + return None + return HouseholdReportRunRecord( + id=_checked(report.report_run_id, UUID), + report_id=_checked(report.report_id, UUID), + trigger=report.trigger.value, + tax_benefit_model_version_id=_checked(report.catalog_version_id, UUID), + policyengine_version=None, + year=_checked(report.year, int), + status=REPORT_STATUSES[report.status.value], + baseline=_calculation(report.baseline), + reform=None if report.reform is None else _calculation(report.reform), + ) + + +def _report(report: HouseholdReportResponse) -> HouseholdReportRecord: + run = _report_run(report) + return HouseholdReportRecord( + id=_checked(report.report_id, UUID), + country_id=_checked(report.country, str), + year=_checked(report.year, int), + household_id=_optional(report.household_id, UUID), + baseline_simulation_id=_checked(report.baseline_simulation_id, UUID), + reform_simulation_id=_optional(report.reform_simulation_id, UUID), + status=ReportStatus.NOT_RUN if run is None else run.status, + run=run, + ) + + +def _report_answer(call: Callable[[], Response[Any]]) -> HouseholdReportRecord: + """Run one report route call and return the report it answered. + + The generated client keeps a nested value it cannot parse as it came, + such as a run status it does not know, a calculation of another shape or + a nullable UUID that is not one, so the record is built and checked here + under the same rule as the parse: an answer that does not parse means the + entrypoint cannot be used. + """ + answer = _parsed(call, HouseholdReportResponse) + try: + return _report(answer) + except (AttributeError, KeyError, TypeError, ValueError) as error: + msg = ( + "the Simulation Entrypoint's answer could not be read: " + f"{type(error).__name__}" + ) + raise SimulationServiceUnavailableError(msg) from error + + +def _policy_choice(choice: HouseholdPolicyChoice) -> GeneratedPolicyChoice: + return GeneratedPolicyChoice(policy_id=choice.policy_id) + + +class SimulationEntrypointHouseholdReports: + """``HouseholdReportSimulation`` over the generated simulation client.""" + + def __init__(self, client: AuthenticatedClient, *, base_url: str) -> None: + """Send every request through ``client``, whose base URL is ``base_url``.""" + self._client = client + self.base_url = base_url + + def submit_report( + self, submission: HouseholdReportSubmission + ) -> HouseholdReportRecord: + """Create a report and its first run in the entrypoint, and start it.""" + body = GeneratedSubmission( + country=HouseholdReportSubmissionCountry(submission.country_id), + policyengine_version=submission.policyengine_version, + household_id=submission.household_id, + baseline=_policy_choice(submission.baseline), + reform=( + None if submission.reform is None else _policy_choice(submission.reform) + ), + options=HouseholdCalculationOptions( + add_missing_variables=submission.add_missing_variables + ), + trigger=GeneratedRunTrigger.INITIAL, + ) + return _report_answer( + lambda: submit_household_report.sync_detailed( + client=self._client, body=body + ) + ) + + def get_report(self, report_id: UUID) -> HouseholdReportRecord: + """Return a report with its newest run and that run's calculations.""" + return _report_answer( + lambda: get_household_report.sync_detailed(report_id, client=self._client) + ) + + def run_report( + self, + report_id: UUID, + *, + policyengine_version: str, + trigger: RunTrigger, + force: bool, + ) -> HouseholdReportRecord: + """Start another run of a report and return the report with that run.""" + body = HouseholdReportRunSubmission( + policyengine_version=policyengine_version, + trigger=GeneratedRunTrigger(trigger.value), + force=force, + ) + return _report_answer( + lambda: run_household_report.sync_detailed( + report_id, client=self._client, body=body + ) + ) + + +def simulation_entrypoint_household_reports( + base_url: str, + *, + auth: httpx.Auth | None, + transport: httpx.BaseTransport | None = None, +) -> SimulationEntrypointHouseholdReports: + """Build the adapter for the entrypoint at ``base_url``. + + ``auth`` adds the bearer token; ``transport`` replaces the network in tests. + """ + http_client = httpx.Client( + base_url=base_url, + timeout=REQUEST_TIMEOUT_SECONDS, + auth=auth, + transport=transport, + event_hooks={"request": [attach_request_identifiers]}, + ) + instrument_httpx(http_client, get_runtime()) + # The httpx client carries the refreshing gateway token, so the generated + # client's own fixed token is never sent. + client = AuthenticatedClient(base_url=base_url, token="").set_httpx_client( + http_client + ) + return SimulationEntrypointHouseholdReports(client, base_url=base_url) + + +def household_reports_from_environment() -> SimulationEntrypointHouseholdReports: + """Build the adapter from ``SIMULATION_ENTRYPOINT_URL`` and the gateway auth. + + Raise ``SimulationServiceUnavailableError`` when either is unusable. + """ + try: + base_url = required_base_url(SIMULATION_ENTRYPOINT_URL_ENV) + auth = gateway_bearer_auth_from_environment() + except (ValueError, GatewayAuthError) as error: + raise SimulationServiceUnavailableError(str(error)) from error + return simulation_entrypoint_household_reports(base_url, auth=auth) + + +@cache +def shared_household_reports() -> SimulationEntrypointHouseholdReports: + """Return one adapter per process, so its gateway token is fetched once.""" + return household_reports_from_environment() diff --git a/policyengine_api/services/v2/economy_reports/services.py b/policyengine_api/services/v2/economy_reports/services.py index 14cb7b8a9..f8823452e 100644 --- a/policyengine_api/services/v2/economy_reports/services.py +++ b/policyengine_api/services/v2/economy_reports/services.py @@ -28,6 +28,9 @@ economy_report_submission, reports_of_country, ) +from policyengine_api.services.v2.economy_reports.types import ( + EconomyReportListing, +) from policyengine_api.services.v2.economy_reports.validators import ( economy_reports_supported, report_not_found, @@ -245,22 +248,32 @@ def list_report_runs( """ return self._runs_in_country(country_id=country_id, report_id=report_id) - def _report_or_none(self, report_id: UUID) -> EconomyReportRecord | None: - """Read a listed report; leave out an unknown one or an unreadable one. + def find_reports( + self, *, country_id: str, report_ids: Sequence[UUID] + ) -> EconomyReportListing: + """List the country's reports among ``report_ids``, and name the unreadable. A report whose stored result cannot be read back is logged and left out - of listings, as the caller can tell from the IDs it asked for. + of the listing, as the caller can tell from the IDs it asked for, and + its ID is named, because it is not missing. """ - try: - return self._simulation.get_report(report_id) - except SimulationRecordNotFoundError: - return None - except SimulationRecordUnreadableError: - logger.warning( - "v2_economy_report_unreadable_in_listing", - extra={"policyengine_attributes": {"report_id": str(report_id)}}, - ) - return None + found: list[EconomyReportRecord] = [] + unreadable_report_ids: list[UUID] = [] + for report_id in dict.fromkeys(report_ids): + try: + found.append(self._simulation.get_report(report_id)) + except SimulationRecordNotFoundError: + continue + except SimulationRecordUnreadableError: + logger.warning( + "v2_economy_report_unreadable_in_listing", + extra={"policyengine_attributes": {"report_id": str(report_id)}}, + ) + unreadable_report_ids.append(report_id) + return EconomyReportListing( + reports=reports_of_country(found, country_id=country_id), + unreadable_report_ids=tuple(unreadable_report_ids), + ) def list_reports( self, *, country_id: str, report_ids: Sequence[UUID] @@ -271,10 +284,4 @@ def list_reports( cannot be read back are left out. Listing a report with no run does not start one; reading it does. """ - return reports_of_country( - ( - self._report_or_none(report_id) - for report_id in dict.fromkeys(report_ids) - ), - country_id=country_id, - ) + return self.find_reports(country_id=country_id, report_ids=report_ids).reports diff --git a/policyengine_api/services/v2/economy_reports/types.py b/policyengine_api/services/v2/economy_reports/types.py index 5ac6108fc..020ed2b0d 100644 --- a/policyengine_api/services/v2/economy_reports/types.py +++ b/policyengine_api/services/v2/economy_reports/types.py @@ -6,7 +6,8 @@ from __future__ import annotations -from typing import Annotated, Literal +from dataclasses import dataclass +from typing import TYPE_CHECKING, Annotated, Literal from pydantic import BaseModel, ConfigDict, Field, StrictBool @@ -14,6 +15,13 @@ SimulationChoice, ) +if TYPE_CHECKING: + from uuid import UUID + + from policyengine_api.services.v2.simulation_entrypoint.types import ( + EconomyReportRecord, + ) + # The bounds and form the Simulation Entrypoint accepts. ReportYear = Annotated[int, Field(ge=1900, le=2200, description="The report's year")] CatalogKey = Annotated[str, Field(min_length=1, max_length=255, pattern=r"^\S+$")] @@ -45,3 +53,15 @@ class EconomyReportRunFields(StrictEconomyReportInput): """A re-run; ``force`` calculates both simulations again instead of reusing runs.""" force: StrictBool = False + + +@dataclass(frozen=True) +class EconomyReportListing: + """The country's economy reports among some IDs, and the IDs left unreadable. + + ``unreadable_report_ids`` are reports the simulation service has but whose + stored result it cannot read back; a listing leaves them out. + """ + + reports: tuple[EconomyReportRecord, ...] + unreadable_report_ids: tuple[UUID, ...] diff --git a/policyengine_api/services/v2/household_reports/__init__.py b/policyengine_api/services/v2/household_reports/__init__.py new file mode 100644 index 000000000..43428b2b6 --- /dev/null +++ b/policyengine_api/services/v2/household_reports/__init__.py @@ -0,0 +1 @@ +"""v2 household reports, calculated and stored by the Simulation Entrypoint.""" diff --git a/policyengine_api/services/v2/household_reports/database_connectors/__init__.py b/policyengine_api/services/v2/household_reports/database_connectors/__init__.py new file mode 100644 index 000000000..feb12af58 --- /dev/null +++ b/policyengine_api/services/v2/household_reports/database_connectors/__init__.py @@ -0,0 +1 @@ +"""Database selections used by the v2 household report service.""" diff --git a/policyengine_api/services/v2/household_reports/database_connectors/reads.py b/policyengine_api/services/v2/household_reports/database_connectors/reads.py new file mode 100644 index 000000000..8bf3f3336 --- /dev/null +++ b/policyengine_api/services/v2/household_reports/database_connectors/reads.py @@ -0,0 +1,37 @@ +"""Database selections used by the v2 household report service.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sqlmodel import col, select + +from policyengine_api.data.v2.models import Country, TaxBenefitModelVersion + +if TYPE_CHECKING: + from collections.abc import Collection + from uuid import UUID + + from sqlmodel import Session + + +def read_country(session: Session, country_id: str) -> Country | None: + """Return the country's countries row, or None when it has none.""" + return session.get(Country, country_id) + + +def read_policyengine_versions( + session: Session, catalog_version_ids: Collection[UUID] +) -> dict[UUID, str]: + """Return the PolicyEngine.py version of each of these catalog versions. + + A catalog version the Public API does not have is left out. + """ + if not catalog_version_ids: + return {} + rows = session.exec( + select(TaxBenefitModelVersion.id, TaxBenefitModelVersion.version).where( + col(TaxBenefitModelVersion.id).in_(catalog_version_ids) + ) + ) + return dict(rows.all()) diff --git a/policyengine_api/services/v2/household_reports/database_session.py b/policyengine_api/services/v2/household_reports/database_session.py new file mode 100644 index 000000000..ab7346c97 --- /dev/null +++ b/policyengine_api/services/v2/household_reports/database_session.py @@ -0,0 +1,30 @@ +"""Database session lifetime management for the v2 household report service.""" + +from __future__ import annotations + +from contextlib import contextmanager +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterator + + from sqlalchemy.orm import sessionmaker + from sqlmodel import Session + + +class HouseholdReportDatabaseSession: + """Open the read sessions the household report service uses. + + Reports themselves live in the Simulation Entrypoint; the Public API reads + what each country supports and its catalog versions. + """ + + def __init__(self, session_factory: sessionmaker[Session]) -> None: + """Use ``session_factory`` to open every session.""" + self._session_factory = session_factory + + @contextmanager + def read(self) -> Iterator[Session]: + """Yield a session for reads; nothing is committed.""" + with self._session_factory() as session: + yield session diff --git a/policyengine_api/services/v2/household_reports/services.py b/policyengine_api/services/v2/household_reports/services.py new file mode 100644 index 000000000..cc6e4fb28 --- /dev/null +++ b/policyengine_api/services/v2/household_reports/services.py @@ -0,0 +1,272 @@ +"""Application services for v2 household reports. + +The Simulation Entrypoint owns household reports and reads the stored +household and policies from the Public API's tables itself. Before asking +it, this service checks what the Public API decides, through the same +services as ``/v2/households`` and ``/v2/policies``: + +- A country's ``countries`` row must allow household calculations; a + household-only country does. +- The household, and each policy a report names, must be stored for the + country; one of another country, or none, is not found. +- The household must have a default year, the year the report calculates. + +Reports run on the current catalog version: the one published for the +PolicyEngine.py version the Public API runs, as economy reports do. A run +this service starts is on that version; the PolicyEngine.py version of a run +read back is looked up in the Public API's own catalog. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from policyengine_api.services.v2.household_reports.database_connectors.reads import ( + read_country, + read_policyengine_versions, +) +from policyengine_api.services.v2.household_reports.transformations import ( + household_report_submission, + run_catalog_version_ids, + with_policyengine_version, +) +from policyengine_api.services.v2.household_reports.validators import ( + require_default_year, + require_household_reports_supported, + require_report_in_country, +) +from policyengine_api.services.v2.simulation_entrypoint.types import ( + RunTrigger, + SimulationRecordNotFoundError, + SimulationServiceUnavailableError, +) + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + from uuid import UUID + + from policyengine_api.services.v2.household_reports.database_session import ( + HouseholdReportDatabaseSession, + ) + from policyengine_api.services.v2.household_reports.types import ( + HouseholdReportFields, + HouseholdReportRecord, + HouseholdReportRunRecord, + HouseholdReportSimulation, + HouseholdReportSubmission, + HouseholdSource, + PolicySource, + ) + + +class DeferredHouseholdReportSimulation: + """``HouseholdReportSimulation`` that builds its adapter at its first call. + + The Public API decides some refusals itself, such as a country without + household calculations or an unknown household, and those must not depend + on the simulation client or its settings. A build that raises + ``SimulationServiceUnavailableError`` is tried again at the next call. + """ + + def __init__(self, build: Callable[[], HouseholdReportSimulation]) -> None: + """Call ``build`` at the first call and reuse its adapter afterwards.""" + self._build = build + self._simulation: HouseholdReportSimulation | None = None + + def _resolved(self) -> HouseholdReportSimulation: + if self._simulation is None: + self._simulation = self._build() + return self._simulation + + def submit_report( + self, submission: HouseholdReportSubmission + ) -> HouseholdReportRecord: + """Create a report and its first run in the entrypoint, and start it.""" + return self._resolved().submit_report(submission) + + def get_report(self, report_id: UUID) -> HouseholdReportRecord: + """Return a report.""" + return self._resolved().get_report(report_id) + + def run_report( + self, + report_id: UUID, + *, + policyengine_version: str, + trigger: RunTrigger, + force: bool, + ) -> HouseholdReportRecord: + """Start another run of a report on the catalog version of a version.""" + return self._resolved().run_report( + report_id, + policyengine_version=policyengine_version, + trigger=trigger, + force=force, + ) + + +class V2HouseholdReportService: + """Sequence household report operations between the v2 tables and sim.""" + + def __init__( + self, + database_session: HouseholdReportDatabaseSession, + *, + households: HouseholdSource, + policies: PolicySource, + simulation: HouseholdReportSimulation, + current_policyengine_version: str, + ) -> None: + """Read countries and catalog versions through ``database_session``. + + Stored households and policies are read through ``households`` and + ``policies``, reports through ``simulation``. + ``current_policyengine_version`` selects the catalog version new runs + use. + """ + self._database_session = database_session + self._households = households + self._policies = policies + self._simulation = simulation + self._current_policyengine_version = current_policyengine_version + + def _require_household_reports(self, country_id: str) -> None: + with self._database_session.read() as session: + country = read_country(session, country_id) + require_household_reports_supported(country, country_id=country_id) + + def _with_current_version( + self, report: HouseholdReportRecord + ) -> HouseholdReportRecord: + """Name the version a run this service just started runs on. + + The entrypoint runs it on the catalog version of the PolicyEngine.py + version sent, so no database read follows the entrypoint's work. + """ + if report.run is None: + return report + catalog_version_id = report.run.tax_benefit_model_version_id + return with_policyengine_version( + report, {catalog_version_id: self._current_policyengine_version} + ) + + def _with_policyengine_versions( + self, reports: tuple[HouseholdReportRecord, ...] + ) -> tuple[HouseholdReportRecord, ...]: + catalog_version_ids = run_catalog_version_ids(reports) + if not catalog_version_ids: + return reports + with self._database_session.read() as session: + versions = read_policyengine_versions(session, catalog_version_ids) + return tuple(with_policyengine_version(report, versions) for report in reports) + + def submit_report( + self, *, country_id: str, report_input: HouseholdReportFields + ) -> HouseholdReportRecord: + """Create a household report and its first run in the entrypoint. + + Raise ``HouseholdReportsUnsupportedError`` for a country without + household calculations, ``HouseholdNotFoundError`` or + ``PolicyNotFoundError`` for a household or policy the country does not + have, and ``HouseholdReportYearError`` for a household without a + default year, all before the entrypoint is asked; and + ``SimulationRequestRefusedError`` when the entrypoint refuses the + household or a policy. + """ + self._require_household_reports(country_id) + household = self._households.get_household( + country_id=country_id, household_id=report_input.household_id + ) + for choice in (report_input.baseline, report_input.reform): + if choice is not None and choice.policy_id is not None: + self._policies.get_policy( + country_id=country_id, policy_id=choice.policy_id + ) + require_default_year(household.default_year) + return self._with_current_version( + self._simulation.submit_report( + household_report_submission( + report_input, + country_id=country_id, + policyengine_version=self._current_policyengine_version, + ) + ) + ) + + def _report_or_none(self, report_id: UUID) -> HouseholdReportRecord | None: + try: + return self._simulation.get_report(report_id) + except SimulationRecordNotFoundError: + return None + + def get_report(self, *, country_id: str, report_id: UUID) -> HouseholdReportRecord: + """Return a household report of the country with its newest run. + + Raise ``HouseholdReportNotFoundError`` when the entrypoint has no such + report in this country. + """ + report = require_report_in_country( + self._report_or_none(report_id), + country_id=country_id, + report_id=report_id, + ) + (report,) = self._with_policyengine_versions((report,)) + return report + + def list_reports( + self, *, country_id: str, report_ids: Sequence[UUID] + ) -> tuple[HouseholdReportRecord, ...]: + """Return the country's household reports among ``report_ids``. + + They are in that order, once each; unknown reports and other + countries' are left out. + """ + found = ( + self._report_or_none(report_id) for report_id in dict.fromkeys(report_ids) + ) + return self._with_policyengine_versions( + tuple( + report + for report in found + if report is not None and report.country_id == country_id + ) + ) + + def run_report( + self, *, country_id: str, report_id: UUID, force: bool + ) -> HouseholdReportRunRecord: + """Run a household report of the country again on the current version. + + The run has the ``manual`` trigger. The entrypoint reuses runs that are + unfinished or succeeded within the last 28 days unless ``force`` is + set, and reads the report's household and policies again. Raise + ``HouseholdReportsUnsupportedError`` for a country without household + calculations before the entrypoint is asked, as for economy reports; + ``HouseholdReportNotFoundError`` when the country has no such report; + and ``SimulationRequestRefusedError`` when the entrypoint refuses the + household or a policy. + """ + self._require_household_reports(country_id) + require_report_in_country( + self._report_or_none(report_id), + country_id=country_id, + report_id=report_id, + ) + try: + report = self._simulation.run_report( + report_id, + policyengine_version=self._current_policyengine_version, + trigger=RunTrigger.MANUAL, + force=force, + ) + except SimulationRecordNotFoundError: + report = None + report = self._with_current_version( + require_report_in_country( + report, country_id=country_id, report_id=report_id + ) + ) + if report.run is None: + msg = "the Simulation Entrypoint answered a re-run without its run" + raise SimulationServiceUnavailableError(msg) + return report.run diff --git a/policyengine_api/services/v2/household_reports/transformations.py b/policyengine_api/services/v2/household_reports/transformations.py new file mode 100644 index 000000000..e9ab8d6e1 --- /dev/null +++ b/policyengine_api/services/v2/household_reports/transformations.py @@ -0,0 +1,62 @@ +"""Conversions between household report inputs and records.""" + +from __future__ import annotations + +from dataclasses import replace +from typing import TYPE_CHECKING + +from policyengine_api.services.v2.household_reports.types import ( + HouseholdReportSubmission, +) + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping + from uuid import UUID + + from policyengine_api.services.v2.household_reports.types import ( + HouseholdReportFields, + HouseholdReportRecord, + ) + + +def household_report_submission( + report_input: HouseholdReportFields, + *, + country_id: str, + policyengine_version: str, +) -> HouseholdReportSubmission: + """Return what the Simulation Entrypoint is asked to create.""" + return HouseholdReportSubmission( + country_id=country_id, + policyengine_version=policyengine_version, + household_id=report_input.household_id, + baseline=report_input.baseline, + reform=report_input.reform, + add_missing_variables=report_input.add_missing_variables, + ) + + +def run_catalog_version_ids(reports: Iterable[HouseholdReportRecord]) -> set[UUID]: + """Return the catalog versions the reports' runs ran on.""" + return { + report.run.tax_benefit_model_version_id + for report in reports + if report.run is not None + } + + +def with_policyengine_version( + report: HouseholdReportRecord, policyengine_versions: Mapping[UUID, str] +) -> HouseholdReportRecord: + """Name the PolicyEngine.py version of the report's run's catalog version.""" + if report.run is None: + return report + return replace( + report, + run=replace( + report.run, + policyengine_version=policyengine_versions.get( + report.run.tax_benefit_model_version_id + ), + ), + ) diff --git a/policyengine_api/services/v2/household_reports/types.py b/policyengine_api/services/v2/household_reports/types.py new file mode 100644 index 000000000..700101322 --- /dev/null +++ b/policyengine_api/services/v2/household_reports/types.py @@ -0,0 +1,233 @@ +"""The Simulation Entrypoint's household report routes, as the Public API uses them. + +A household report calculates one stored household under a baseline and, +optionally, a reform, each a stored policy or current law, for the +household's ``default_year``. The Simulation Entrypoint owns household +reports: their definitions, runs and calculated households live in its +database, and it reads the household and the policies from the Public API's +tables itself. ``HouseholdReportSimulation`` is those routes, with the +records they answer; they raise the errors of +``policyengine_api.services.v2.simulation_entrypoint.types``. +``policyengine_api.libs.simulation_household_reports`` implements it with the +generated simulation client, and tests replace it with a fake. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal, Protocol + +from pydantic import BaseModel, ConfigDict, StrictBool + +from policyengine_api.query_parameters import ResourceId + +if TYPE_CHECKING: + from datetime import datetime + from uuid import UUID + + from policyengine_api.services.v2.simulation_entrypoint.types import ( + JSONObject, + ReportStatus, + RunTrigger, + ) + + +class StrictHouseholdReportInput(BaseModel): + """Reject fields outside the reviewed household report contract.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + +class HouseholdPolicyChoice(StrictHouseholdReportInput): + """A stored v2 policy of the report's country, or none for current law.""" + + policy_id: ResourceId | None = None + + +class HouseholdReportFields(StrictHouseholdReportInput): + """What a household report calculates, apart from its country. + + ``household_id`` names a household stored through ``/v2/households``. + ``reform`` is null for a report of the baseline alone. The report is for + the household's ``default_year``. With ``add_missing_variables``, each + calculation adds every yearly, monthly and eternal variable the household + lacks, as ``/calculate-full`` does; otherwise it fills in only the values + the household asks for. + """ + + kind: Literal["household"] + household_id: ResourceId + baseline: HouseholdPolicyChoice + reform: HouseholdPolicyChoice | None = None + add_missing_variables: StrictBool = False + + +@dataclass(frozen=True) +class HouseholdReportSubmission: + """A new household report as the Simulation Entrypoint is asked to create it. + + ``policyengine_version`` selects the country's catalog version. + """ + + country_id: str + policyengine_version: str + household_id: UUID + baseline: HouseholdPolicyChoice + reform: HouseholdPolicyChoice | None + add_missing_variables: bool = False + + +@dataclass(frozen=True) +class HouseholdCalculationError: + """Why a household calculation failed. + + ``code`` is the run's error code, such as ``household_calculation_failed``, + when the household function refused the inputs; ``detail`` then holds its + error, with the Public API's error codes and messages. + """ + + code: str + message: str + detail: JSONObject | None + + +@dataclass(frozen=True) +class HouseholdCalculationOutput: + """A succeeded calculation: the household with its requested values filled in. + + ``household`` keeps the stored household's key order. ``spm_config`` is + the SPM selection applied and ``spm_provenance`` its receipt, both None + for a country without SPM. + """ + + household: JSONObject + warnings: tuple[str, ...] + spm_config: JSONObject | None + spm_provenance: JSONObject | None + + +@dataclass(frozen=True) +class HouseholdCalculationRecord: + """One calculation of the household under one policy: a simulation run. + + ``status`` is ``pending``, ``dispatching``, ``running``, ``succeeded`` or + ``failed``; ``attempt`` counts forced recalculations. ``result`` is set + once it succeeded and ``error`` once it failed. + """ + + id: UUID + simulation_id: UUID + policy_id: UUID | None + status: str + attempt: int + trigger: str + error: HouseholdCalculationError | None + result: HouseholdCalculationOutput | None + dispatched_at: datetime | None + completed_at: datetime | None + created_at: datetime + + +@dataclass(frozen=True) +class HouseholdReportRunRecord: + """One calculation of a household report: the baseline's and the reform's. + + ``tax_benefit_model_version_id`` is the catalog version it ran on and + ``policyengine_version`` that version's PolicyEngine.py version: the one + the Public API sent, for a run it just started, or the one its own + catalog records, for a run read back (None if the catalog lacks it). + ``reform`` is None for a report of the baseline alone. + """ + + id: UUID + report_id: UUID + trigger: str + tax_benefit_model_version_id: UUID + policyengine_version: str | None + year: int + status: ReportStatus + baseline: HouseholdCalculationRecord + reform: HouseholdCalculationRecord | None + + +@dataclass(frozen=True) +class HouseholdReportRecord: + """A household report with its newest run, or the run a request started. + + ``status`` is the run's, or ``not_run`` before the first run, when + ``household_id`` is not known either. + """ + + id: UUID + country_id: str + year: int + household_id: UUID | None + baseline_simulation_id: UUID + reform_simulation_id: UUID | None + status: ReportStatus + run: HouseholdReportRunRecord | None + + +class HouseholdReportSimulation(Protocol): + """The Simulation Entrypoint's household report routes. + + Every method raises ``SimulationServiceUnavailableError`` when the + entrypoint cannot be used. + """ + + def submit_report( + self, submission: HouseholdReportSubmission + ) -> HouseholdReportRecord: + """Create the report and its first run, and start it. + + Raise ``SimulationRequestRefusedError`` when the entrypoint refuses the + caller's data, with its reason. + """ + ... + + def get_report(self, report_id: UUID) -> HouseholdReportRecord: + """Return a report; raise ``SimulationRecordNotFoundError`` if unknown.""" + ... + + def run_report( + self, + report_id: UUID, + *, + policyengine_version: str, + trigger: RunTrigger, + force: bool, + ) -> HouseholdReportRecord: + """Start another run on the catalog version of ``policyengine_version``. + + Return the report with the new run. Runs that are unfinished or + succeeded within the last 28 days are reused unless ``force`` is set. + Raise ``SimulationRecordNotFoundError`` for an unknown report and + ``SimulationRequestRefusedError`` when the entrypoint refuses the + caller's data. + """ + ... + + +class StoredHousehold(Protocol): + """A household stored through ``/v2/households``, as far as a report needs it.""" + + @property + def default_year(self) -> int | None: + """The year the household's values name, which a report calculates.""" + ... + + +class HouseholdSource(Protocol): + """Read stored households, as ``/v2/households`` does (``V2HouseholdService``).""" + + def get_household(self, *, country_id: str, household_id: UUID) -> StoredHousehold: + """Return the country's household, or raise ``HouseholdNotFoundError``.""" + ... + + +class PolicySource(Protocol): + """Read stored policies, as ``/v2/policies`` does (``V2PolicyService``).""" + + def get_policy(self, *, country_id: str, policy_id: UUID) -> object: + """Return the country's policy, or raise ``PolicyNotFoundError``.""" + ... diff --git a/policyengine_api/services/v2/household_reports/validators.py b/policyengine_api/services/v2/household_reports/validators.py new file mode 100644 index 000000000..f6c46fee3 --- /dev/null +++ b/policyengine_api/services/v2/household_reports/validators.py @@ -0,0 +1,59 @@ +"""Database-independent validation for v2 household report operations.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from uuid import UUID + + from policyengine_api.data.v2.models import Country + from policyengine_api.services.v2.household_reports.types import ( + HouseholdReportRecord, + ) + + +class HouseholdReportsUnsupportedError(ValueError): + """The country does not have household calculations. + + Its countries row leaves ``household_enabled`` unset, or it has no row. A + household-only country has them. + """ + + +class HouseholdReportYearError(ValueError): + """The stored household has no default year, the year a report calculates.""" + + +class HouseholdReportNotFoundError(LookupError): + """The simulation service has no such household report in the country.""" + + +def require_household_reports_supported( + country: Country | None, *, country_id: str +) -> None: + """Raise ``HouseholdReportsUnsupportedError`` unless the row allows them.""" + if country is None or not country.household_enabled: + msg = f"Household reports are not available for country {country_id!r}" + raise HouseholdReportsUnsupportedError(msg) + + +def require_default_year(default_year: int | None) -> int: + """Return the household's default year, or raise ``HouseholdReportYearError``.""" + if default_year is None: + msg = "This household has no default year to calculate." + raise HouseholdReportYearError(msg) + return default_year + + +def require_report_in_country( + report: HouseholdReportRecord | None, *, country_id: str, report_id: UUID +) -> HouseholdReportRecord: + """Return the report, or raise ``HouseholdReportNotFoundError``. + + A report of another country is not found, as for economy reports. + """ + if report is None or report.country_id != country_id: + msg = f"Household report {report_id} was not found in country {country_id!r}" + raise HouseholdReportNotFoundError(msg) + return report diff --git a/policyengine_api/services/v2/report_kinds/__init__.py b/policyengine_api/services/v2/report_kinds/__init__.py new file mode 100644 index 000000000..cb498aa49 --- /dev/null +++ b/policyengine_api/services/v2/report_kinds/__init__.py @@ -0,0 +1 @@ +"""Serve /v2/reports for both kinds of report: economy and household.""" diff --git a/policyengine_api/services/v2/report_kinds/services.py b/policyengine_api/services/v2/report_kinds/services.py new file mode 100644 index 000000000..ae186c83c --- /dev/null +++ b/policyengine_api/services/v2/report_kinds/services.py @@ -0,0 +1,192 @@ +"""Serve ``/v2/reports`` for both kinds of report: economy and household. + +A submission names its kind. Each kind's service, and the entrypoint's +routes behind it, handle only reports of that kind, so a report read by its +ID is looked for among economy reports first and then among household +reports. A failure other than "not found", such as an unavailable +simulation service, is never taken for a missing report. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from policyengine_api.services.v2.economy_reports.validators import ( + EconomyReportNotFoundError, + EconomyReportsUnsupportedError, +) +from policyengine_api.services.v2.household_reports.types import ( + HouseholdReportFields, +) +from policyengine_api.services.v2.household_reports.validators import ( + HouseholdReportNotFoundError, + HouseholdReportsUnsupportedError, +) +from policyengine_api.services.v2.report_kinds.validators import report_not_found + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + from uuid import UUID + + from policyengine_api.services.v2.economy_reports.types import EconomyReportFields + from policyengine_api.services.v2.household_reports.types import ( + HouseholdReportRecord, + HouseholdReportRunRecord, + ) + from policyengine_api.services.v2.report_kinds.types import ( + EconomyReportOperations, + HouseholdReportOperations, + ) + from policyengine_api.services.v2.simulation_entrypoint.types import ( + EconomyReportRecord, + EconomyReportRunRecord, + ) + + +class V2ReportService: + """Send each ``/v2/reports`` operation to the economy or household service. + + Each service is built from its factory the first time it is needed. + """ + + def __init__( + self, + *, + economy_reports: Callable[[], EconomyReportOperations], + household_reports: Callable[[], HouseholdReportOperations], + ) -> None: + """Build each kind's service with its factory, at its first use.""" + self._economy_factory = economy_reports + self._household_factory = household_reports + self._economy: EconomyReportOperations | None = None + self._household: HouseholdReportOperations | None = None + + def _economy_reports(self) -> EconomyReportOperations: + if self._economy is None: + self._economy = self._economy_factory() + return self._economy + + def _household_reports(self) -> HouseholdReportOperations: + if self._household is None: + self._household = self._household_factory() + return self._household + + def submit_report( + self, + *, + country_id: str, + report_input: EconomyReportFields | HouseholdReportFields, + ) -> EconomyReportRecord | HouseholdReportRecord: + """Create a report of the submission's kind and start its first run.""" + if isinstance(report_input, HouseholdReportFields): + return self._household_reports().submit_report( + country_id=country_id, report_input=report_input + ) + return self._economy_reports().submit_report( + country_id=country_id, report_input=report_input + ) + + def get_report( + self, *, country_id: str, report_id: UUID + ) -> EconomyReportRecord | HouseholdReportRecord: + """Return the country's report of either kind. + + An economy report with no run starts its first run, as + ``V2EconomyReportService.get_report`` does. Raise + ``ReportNotFoundError`` when the country has neither kind by this ID. + """ + try: + return self._economy_reports().get_report( + country_id=country_id, report_id=report_id + ) + except EconomyReportNotFoundError: + pass + try: + return self._household_reports().get_report( + country_id=country_id, report_id=report_id + ) + except HouseholdReportNotFoundError as error: + raise report_not_found( + country_id=country_id, reference=str(report_id) + ) from error + + def get_report_by_legacy_number( + self, *, country_id: str, legacy_report_id: int + ) -> EconomyReportRecord: + """Return the economy report migrated from a v1 report output number.""" + return self._economy_reports().get_report_by_legacy_number( + country_id=country_id, legacy_report_id=legacy_report_id + ) + + def run_report( + self, *, country_id: str, report_id: UUID, force: bool + ) -> EconomyReportRunRecord | HouseholdReportRunRecord: + """Run the country's report of either kind again on the current version. + + A household-only country has no economy reports, so its report is + looked for among household reports at once. When the country has + neither kind by this ID, the economy refusal of a household-only + country (``EconomyReportsUnsupportedError``) stands, and otherwise + ``ReportNotFoundError`` is raised. + """ + try: + return self._economy_reports().run_report( + country_id=country_id, report_id=report_id, force=force + ) + except (EconomyReportNotFoundError, EconomyReportsUnsupportedError) as error: + economy_refusal: Exception = error + try: + return self._household_reports().run_report( + country_id=country_id, report_id=report_id, force=force + ) + except ( + HouseholdReportNotFoundError, + HouseholdReportsUnsupportedError, + ) as error: + # A country with neither kind keeps the economy refusal it had. + if isinstance(economy_refusal, EconomyReportsUnsupportedError): + raise economy_refusal from error + if isinstance(error, HouseholdReportsUnsupportedError): + raise + raise report_not_found( + country_id=country_id, reference=str(report_id) + ) from error + + def list_reports( + self, *, country_id: str, report_ids: Sequence[UUID] + ) -> tuple[EconomyReportRecord | HouseholdReportRecord, ...]: + """Return the country's reports of both kinds among ``report_ids``. + + They are in that order, once each. IDs that are not economy reports + the simulation service has are looked for among the country's + household reports; an economy report whose stored result cannot be + read back is left out, as before, without that lookup. + """ + requested = tuple(dict.fromkeys(report_ids)) + economy_listing = self._economy_reports().find_reports( + country_id=country_id, report_ids=requested + ) + found: dict[UUID, EconomyReportRecord | HouseholdReportRecord] = { + report.id: report for report in economy_listing.reports + } + # An economy report whose result cannot be read back is not missing. + not_missing = set(found) | set(economy_listing.unreadable_report_ids) + remaining = [ + report_id for report_id in requested if report_id not in not_missing + ] + if remaining: + found.update( + (report.id, report) + for report in self._household_reports().list_reports( + country_id=country_id, report_ids=remaining + ) + ) + return tuple(found[report_id] for report_id in requested if report_id in found) + + def list_report_runs( + self, *, country_id: str, report_id: UUID + ) -> tuple[EconomyReportRunRecord, ...]: + """Return an economy report's runs, newest first.""" + return self._economy_reports().list_report_runs( + country_id=country_id, report_id=report_id + ) diff --git a/policyengine_api/services/v2/report_kinds/types.py b/policyengine_api/services/v2/report_kinds/types.py new file mode 100644 index 000000000..b3dc21850 --- /dev/null +++ b/policyengine_api/services/v2/report_kinds/types.py @@ -0,0 +1,102 @@ +"""The economy and household report operations that ``/v2/reports`` serves.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + from collections.abc import Sequence + from uuid import UUID + + from policyengine_api.services.v2.economy_reports.types import ( + EconomyReportFields, + EconomyReportListing, + ) + from policyengine_api.services.v2.household_reports.types import ( + HouseholdReportFields, + HouseholdReportRecord, + HouseholdReportRunRecord, + ) + from policyengine_api.services.v2.simulation_entrypoint.types import ( + EconomyReportRecord, + EconomyReportRunRecord, + ) + + +class EconomyReportOperations(Protocol): + """Economy report operations (``V2EconomyReportService``).""" + + def submit_report( + self, *, country_id: str, report_input: EconomyReportFields + ) -> EconomyReportRecord: + """Create a report and its first run on the current catalog version.""" + ... + + def get_report(self, *, country_id: str, report_id: UUID) -> EconomyReportRecord: + """Return a report of the country, starting its first run if it has none.""" + ... + + def get_report_by_legacy_number( + self, *, country_id: str, legacy_report_id: int + ) -> EconomyReportRecord: + """Return the report migrated from a v1 report output number.""" + ... + + def run_report( + self, *, country_id: str, report_id: UUID, force: bool + ) -> EconomyReportRunRecord: + """Run a report again on the current catalog version.""" + ... + + def find_reports( + self, *, country_id: str, report_ids: Sequence[UUID] + ) -> EconomyReportListing: + """List the country's reports among ``report_ids``, naming the unreadable.""" + ... + + def list_report_runs( + self, *, country_id: str, report_id: UUID + ) -> tuple[EconomyReportRunRecord, ...]: + """Return a report's runs, newest first.""" + ... + + +class HouseholdReportOperations(Protocol): + """Household report operations (``V2HouseholdReportService``).""" + + def submit_report( + self, *, country_id: str, report_input: HouseholdReportFields + ) -> HouseholdReportRecord: + """Create a household report and its first run on the current version. + + Raise ``HouseholdReportsUnsupportedError`` for a country without + household calculations, ``HouseholdNotFoundError`` or + ``PolicyNotFoundError`` for a household or policy the country lacks, + ``HouseholdReportYearError`` for a household without a default year, + and ``SimulationRequestRefusedError`` when the entrypoint refuses it. + """ + ... + + def get_report(self, *, country_id: str, report_id: UUID) -> HouseholdReportRecord: + """Return a household report of the country with its newest run. + + Raise ``HouseholdReportNotFoundError`` when the country has no such one. + """ + ... + + def list_reports( + self, *, country_id: str, report_ids: Sequence[UUID] + ) -> tuple[HouseholdReportRecord, ...]: + """Return the country's household reports among ``report_ids``.""" + ... + + def run_report( + self, *, country_id: str, report_id: UUID, force: bool + ) -> HouseholdReportRunRecord: + """Run a household report again on the current version (trigger ``manual``). + + Raise ``HouseholdReportNotFoundError`` for a report the country lacks + and ``HouseholdReportsUnsupportedError`` for a country without + household calculations. + """ + ... diff --git a/policyengine_api/services/v2/report_kinds/validators.py b/policyengine_api/services/v2/report_kinds/validators.py new file mode 100644 index 000000000..6c29a4257 --- /dev/null +++ b/policyengine_api/services/v2/report_kinds/validators.py @@ -0,0 +1,14 @@ +"""Database-independent validation for ``/v2/reports`` across both kinds.""" + +from __future__ import annotations + + +class ReportNotFoundError(LookupError): + """The country has neither an economy report nor a household report by this ID.""" + + +def report_not_found(*, country_id: str, reference: str) -> ReportNotFoundError: + """Build the error for a report of neither kind in the country.""" + return ReportNotFoundError( + f"Report {reference} was not found in country {country_id!r}" + ) diff --git a/policyengine_api/services/v2/simulation_refs/services.py b/policyengine_api/services/v2/simulation_refs/services.py index 44e0c24ca..733cca2b8 100644 --- a/policyengine_api/services/v2/simulation_refs/services.py +++ b/policyengine_api/services/v2/simulation_refs/services.py @@ -3,7 +3,9 @@ Reports and simulations live in the Simulation Entrypoint's database, so the v2 tables that save them keep no foreign key to them. Before a v2 ID is saved, the entrypoint is asked whether that record exists in the country. -The check runs before the caller opens its v2 transaction. +The check runs before the caller opens its v2 transaction. A report ID that +is not one of the country's economy reports may be one of its household +reports, which the entrypoint serves through other routes. """ from __future__ import annotations @@ -24,6 +26,9 @@ if TYPE_CHECKING: from uuid import UUID + from policyengine_api.services.v2.household_reports.types import ( + HouseholdReportSimulation, + ) from policyengine_api.services.v2.simulation_entrypoint.types import ( EconomyReportRecord, EconomyReportSimulation, @@ -32,11 +37,31 @@ class SimulationReferenceService: - """Existence checks of v2 reports and simulations, one entrypoint read each.""" + """Existence checks of v2 reports and simulations with the entrypoint.""" + + def __init__( + self, + simulation: EconomyReportSimulation, + *, + household_reports: HouseholdReportSimulation | None = None, + ) -> None: + """Read economy reports and simulations through ``simulation``. - def __init__(self, simulation: EconomyReportSimulation) -> None: - """Read reports and simulations through ``simulation``.""" + With ``household_reports``, a report ID that is not one of the + country's economy reports is also looked for among its household + reports. + """ self._simulation = simulation + self._household_reports = household_reports + + def _household_report_country(self, report_id: UUID) -> str | None: + """Return a household report's country, or None without one.""" + if self._household_reports is None: + return None + try: + return self._household_reports.get_report(report_id).country_id + except SimulationRecordNotFoundError: + return None def require_report( self, @@ -60,19 +85,23 @@ def require_report( report: EconomyReportRecord | None = self._simulation.get_report(report_id) except SimulationRecordNotFoundError: report = None - require_reference_in_country( - None if report is None else report.country_id, - kind="report", - reference_id=report_id, - country_id=country_id, - ) - if report is not None: - require_migrated_from( - report.legacy_report_id, + if report is None or report.country_id != country_id: + # A household report is never migrated from a v1 report output. + require_reference_in_country( + self._household_report_country(report_id), kind="report", - record_id=report_id, - legacy_id=legacy_report_id, + reference_id=report_id, + country_id=country_id, ) + found_legacy_id = None + else: + found_legacy_id = report.legacy_report_id + require_migrated_from( + found_legacy_id, + kind="report", + record_id=report_id, + legacy_id=legacy_report_id, + ) def _require_report_country(self, *, country_id: str, report_id: UUID) -> None: try: @@ -83,6 +112,8 @@ def _require_report_country(self, *, country_id: str, report_id: UUID) -> None: ) except SimulationRecordNotFoundError: found_country_id = None + if found_country_id != country_id: + found_country_id = self._household_report_country(report_id) require_reference_in_country( found_country_id, kind="report", diff --git a/pyproject.toml b/pyproject.toml index 66c6a592d..81e04402f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -207,8 +207,8 @@ showcontent = true # registers an exit hook at import, the v2 database sessions and services load # persistence only when a default factory runs, Auth0 Management and the # Google Secret Manager client load only when a secret is read, and the -# economy report adapter needs the generated simulation client, which is not -# yet a dependency, only when an economy report route runs. Ruff's PLC0415 +# economy and household report adapters need the generated simulation client, +# which is not yet a dependency, only when a report route runs. Ruff's PLC0415 # accepts in-function imports of these without a suppression. A module belongs # here only if no application module imports it at module level. banned-module-level-imports = [ @@ -217,10 +217,13 @@ banned-module-level-imports = [ "policyengine_api.data.v2.database", "policyengine_api.libs.auth0_management", "policyengine_api.libs.simulation_economy_reports", + "policyengine_api.libs.simulation_household_reports", "policyengine_api.services.v2.countries.database_session", "policyengine_api.services.v2.countries.services", "policyengine_api.services.v2.economy_reports.database_session", "policyengine_api.services.v2.economy_reports.services", + "policyengine_api.services.v2.household_reports.database_session", + "policyengine_api.services.v2.household_reports.services", "policyengine_api.services.v2.households.database_session", "policyengine_api.services.v2.households.services", "policyengine_api.services.v2.legacy_refs.database_session", @@ -261,6 +264,7 @@ runtime-evaluated-base-classes = [ "policyengine_api.fastapi_routes.v2.metadata.response_models.MetadataResourceSuccessResponse", "policyengine_api.fastapi_routes.v2.policies.response_models.PolicySuccessResponse", "policyengine_api.fastapi_routes.v2.policies.response_models.StrictPolicyAPIModel", + "policyengine_api.fastapi_routes.v2.reports.household_response_models.StrictHouseholdReportAPIModel", "policyengine_api.fastapi_routes.v2.reports.response_models.ReportSuccessResponse", "policyengine_api.fastapi_routes.v2.reports.response_models.StrictReportAPIModel", "policyengine_api.fastapi_routes.v2.user_households.response_models.StrictUserHouseholdAPIModel", @@ -281,6 +285,9 @@ runtime-evaluated-base-classes = [ "policyengine_api.services.v2.economy_reports.types.EconomyReportFields", "policyengine_api.services.v2.economy_reports.types.EconomyReportRunFields", "policyengine_api.services.v2.economy_reports.types.StrictEconomyReportInput", + "policyengine_api.services.v2.household_reports.types.HouseholdPolicyChoice", + "policyengine_api.services.v2.household_reports.types.HouseholdReportFields", + "policyengine_api.services.v2.household_reports.types.StrictHouseholdReportInput", "policyengine_api.services.v2.households.types.HouseholdCreationInput", "policyengine_api.services.v2.households.types.StrictHouseholdInput", "policyengine_api.services.v2.metadata.types.MetadataModelVersion", diff --git a/tests/contract/registry.py b/tests/contract/registry.py index 250c3078a..e522d8500 100644 --- a/tests/contract/registry.py +++ b/tests/contract/registry.py @@ -695,6 +695,63 @@ class WorkflowContract: ), ), ), + WorkflowContract( + name="household_reports_v2", + current_contract="typed_v2_resources", + future_owner_pr="Household reports on v2", + requests=( + ContractRequest( + method="POST", + path="/v2/reports?country_id=uk", + expected_status=201, + stable_response_fields=( + "status", + "message", + "result.item.id", + "result.item.country_id", + "result.item.kind", + "result.item.year", + "result.item.household_id", + "result.item.baseline_simulation_id", + "result.item.reform_simulation_id", + "result.item.status", + "result.item.run", + ), + route_group="report", + ), + ContractRequest( + method="GET", + path="/v2/reports/{report_id}?country_id=uk", + expected_status=200, + stable_response_fields=( + "status", + "message", + "result.item.id", + "result.item.kind", + "result.item.status", + "result.item.run.baseline.result", + "result.item.run.reform.result", + ), + route_group="report", + ), + ContractRequest( + method="POST", + path="/v2/reports/{report_id}/runs?country_id=uk", + expected_status=201, + stable_response_fields=( + "status", + "message", + "result.item.kind", + "result.item.id", + "result.item.trigger", + "result.item.tax_benefit_model_version_id", + "result.item.policyengine_version", + "result.item.status", + ), + route_group="report", + ), + ), + ), WorkflowContract( name="metadata_resources_v2_preview", current_contract="typed_v2_resources", diff --git a/tests/contract/test_app_v2_workflow_contracts.py b/tests/contract/test_app_v2_workflow_contracts.py index abdcb3ba7..968989cc4 100644 --- a/tests/contract/test_app_v2_workflow_contracts.py +++ b/tests/contract/test_app_v2_workflow_contracts.py @@ -22,6 +22,7 @@ def test_app_v2_workflow_contract_registry_is_complete(): "region_selection", "country_selection_v2", "economy_reports_v2", + "household_reports_v2", "metadata_resources_v2_preview", "simulation_submit_poll", "report_create_poll", @@ -35,6 +36,7 @@ def test_app_v2_workflow_contract_registry_is_complete(): in { "country_selection_v2", "economy_reports_v2", + "household_reports_v2", "metadata_resources_v2_preview", "policy_resources_v2", "user_policy_associations_v2", diff --git a/tests/integration/test_v2_household_reports.py b/tests/integration/test_v2_household_reports.py new file mode 100644 index 000000000..f94a3c43e --- /dev/null +++ b/tests/integration/test_v2_household_reports.py @@ -0,0 +1,252 @@ +"""Household reports against a migrated disposable Postgres database. + +The services read the countries row, the stored household and the catalog +version as the Public API's runtime role, so the tests prove its declared +privileges cover them (rule ``api.v2.runtime-privileges-tested-as-runtime-role``). +The simulation service is a fake: these tests check what the Public API +decides and reads around it. +""" + +from __future__ import annotations + +import pytest +from alembic import command +from alembic.config import Config +from fastapi.testclient import TestClient +from flask import Flask +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import NullPool +from sqlmodel import Session + +from policyengine_api.asgi_factory import create_asgi_app +from policyengine_api.constants import REPO +from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies +from policyengine_api.migration_flags import ( + RouteImplementation, + RouteImplementationSettings, +) +from policyengine_api.services.v2.economy_reports.database_session import ( + EconomyReportDatabaseSession, +) +from policyengine_api.services.v2.economy_reports.services import ( + V2EconomyReportService, +) +from policyengine_api.services.v2.household_reports.database_session import ( + HouseholdReportDatabaseSession, +) +from policyengine_api.services.v2.household_reports.services import ( + V2HouseholdReportService, +) +from policyengine_api.services.v2.households.database_session import ( + HouseholdDatabaseSession, +) +from policyengine_api.services.v2.households.services import V2HouseholdService +from policyengine_api.services.v2.households.types import ( + NativeHouseholdCreationInput, +) +from policyengine_api.services.v2.policies.database_session import ( + PolicyDatabaseSession, +) +from policyengine_api.services.v2.policies.services import V2PolicyService +from tests.unit.v2.economy_report_support import ( + CURRENT_POLICYENGINE_VERSION, + FakeEconomyReportSimulation, +) +from tests.unit.v2.household_report_support import ( + HOUSEHOLD_CATALOG_VERSION_ID, + FakeHouseholdReportSimulation, +) + +# A model name no catalog publication uses, so the test rows never collide. +TEST_MODEL_NAME = "household-report-test" +# One adult in each of the US groups, in the v2 household form. +HOUSEHOLD_MEMBERSHIPS = { + "household": "household-1", + "family": "family-1", + "tax_unit": "tax-unit-1", + "spm_unit": "spm-unit-1", + "marital_unit": "marital-unit-1", +} +HOUSEHOLD_DATA = { + "people": [ + { + "id": "person-1", + "values": {"age": {"2026": 40}}, + "memberships": HOUSEHOLD_MEMBERSHIPS, + } + ], + **{ + collection: [{"id": identifier, "values": {}}] + for collection, identifier in HOUSEHOLD_MEMBERSHIPS.items() + }, +} + + +@pytest.fixture +def runtime_sessions(managed_v2_roles, v2_engine_as_role, disposable_v2_database_url): + """Migrate, make the US household-only, add a catalog version, and yield sessions. + + The sessions act as the runtime role. The US row and the test catalog + version are restored and removed afterwards, and so is any household a + test stored: later tests in the same database count households. + """ + command.upgrade(Config(str(REPO / "alembic-v2.ini")), "head") + owner_engine = create_engine(disposable_v2_database_url, poolclass=NullPool) + runtime_engine = v2_engine_as_role(managed_v2_roles.runtime, poolclass=NullPool) + with owner_engine.begin() as connection: + households_before = set( + connection.execute(text("SELECT id FROM households")).scalars() + ) + connection.execute( + text("UPDATE countries SET society_wide_enabled = false WHERE id = 'us'") + ) + model_id = connection.execute( + text( + "INSERT INTO tax_benefit_models (id, name, created_at) " + "VALUES (gen_random_uuid(), :name, now()) RETURNING id" + ), + {"name": TEST_MODEL_NAME}, + ).scalar_one() + connection.execute( + text( + "INSERT INTO tax_benefit_model_versions " + "(id, model_id, version, current_law_id, metadata_time_periods, " + "created_at) VALUES (:id, :model_id, :version, 1, '[2026]', now())" + ), + { + "id": HOUSEHOLD_CATALOG_VERSION_ID, + "model_id": model_id, + "version": CURRENT_POLICYENGINE_VERSION, + }, + ) + try: + yield sessionmaker(runtime_engine, class_=Session) + finally: + # Each step in its own transaction: a failed household removal must + # not undo restoring the US row and removing the catalog version. + with owner_engine.begin() as connection: + connection.execute( + text("UPDATE countries SET society_wide_enabled = true WHERE id = 'us'") + ) + connection.execute( + text("DELETE FROM tax_benefit_models WHERE name = :name"), + {"name": TEST_MODEL_NAME}, + ) + with owner_engine.begin() as connection: + stored_by_the_test = ( + set(connection.execute(text("SELECT id FROM households")).scalars()) + - households_before + ) + if stored_by_the_test: + connection.execute( + text("DELETE FROM households WHERE id = ANY(:household_ids)"), + {"household_ids": list(stored_by_the_test)}, + ) + # Read after the removal committed, as a later test would. + with owner_engine.connect() as connection: + households_after = set( + connection.execute(text("SELECT id FROM households")).scalars() + ) + owner_engine.dispose() + runtime_engine.dispose() + assert households_after == households_before, "the test left households behind" + + +def _client(sessions, simulation: FakeHouseholdReportSimulation) -> TestClient: + households = V2HouseholdService(HouseholdDatabaseSession(sessions)) + dependencies = NativeRouteDependencies( + readiness_probe=lambda: True, + gateway_client_factory=lambda: None, + metadata_reader_factory=lambda: None, + specification_provider=dict, + v2_economy_report_service_factory=lambda: V2EconomyReportService( + EconomyReportDatabaseSession(sessions), + FakeEconomyReportSimulation(), + current_policyengine_version=CURRENT_POLICYENGINE_VERSION, + ), + v2_household_report_service_factory=lambda: V2HouseholdReportService( + HouseholdReportDatabaseSession(sessions), + households=households, + policies=V2PolicyService( + PolicyDatabaseSession(sessions), + running_policyengine_version=CURRENT_POLICYENGINE_VERSION, + ), + simulation=simulation, + current_policyengine_version=CURRENT_POLICYENGINE_VERSION, + ), + ) + settings = RouteImplementationSettings( + health=RouteImplementation.FLASK_FALLBACK, + specification=RouteImplementation.FLASK_FALLBACK, + metadata=RouteImplementation.FLASK_FALLBACK, + ) + return TestClient( + create_asgi_app( + Flask(__name__), dependencies=dependencies, route_settings=settings + ) + ) + + +def test_postgres_household_only_country_runs_household_reports_as_the_runtime_role( + runtime_sessions, +) -> None: + household = ( + V2HouseholdService(HouseholdDatabaseSession(runtime_sessions)) + .create_household( + NativeHouseholdCreationInput( + country_id="us", default_year=2026, household_data=HOUSEHOLD_DATA + ) + ) + .item + ) + simulation = FakeHouseholdReportSimulation() + client = _client(runtime_sessions, simulation) + + submitted = client.post( + "/v2/reports?country_id=us", + json={ + "country_id": "us", + "kind": "household", + "household_id": str(household.id), + "baseline": {}, + }, + ) + assert submitted.status_code == 201, submitted.text + report_id = submitted.json()["result"]["item"]["id"] + read = client.get(f"/v2/reports/{report_id}?country_id=us") + rerun = client.post(f"/v2/reports/{report_id}/runs?country_id=us", json={}) + + assert read.status_code == 200, read.text + assert rerun.status_code == 201, rerun.text + assert read.json()["result"]["item"]["run"]["policyengine_version"] == ( + CURRENT_POLICYENGINE_VERSION + ) + assert rerun.json()["result"]["item"]["kind"] == "household" + assert [name for name, _argument in simulation.calls] == [ + "submit_report", + "get_report", + "get_report", + "run_report", + ] + + +def test_postgres_household_only_country_still_refuses_economy_reports( + runtime_sessions, +) -> None: + response = _client(runtime_sessions, FakeHouseholdReportSimulation()).post( + "/v2/reports?country_id=us", + json={ + "country_id": "us", + "kind": "economy", + "year": 2026, + "region_code": "state/ca", + "baseline": {}, + "reform": {}, + }, + ) + + assert response.status_code == 422, response.text + assert response.json()["message"] == ( + "Economy reports are not available for country 'us'" + ) diff --git a/tests/unit/libs/test_simulation_household_reports.py b/tests/unit/libs/test_simulation_household_reports.py new file mode 100644 index 000000000..d10029116 --- /dev/null +++ b/tests/unit/libs/test_simulation_household_reports.py @@ -0,0 +1,704 @@ +"""The household report adapter against the generated simulation client. + +The client is generated from the Simulation Entrypoint's OpenAPI document and +is not a dependency until a release contains the household report routes, so +these tests skip when it is not installed or lacks those routes. Requests go +to an in-memory transport that answers as the entrypoint does. +""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from uuid import UUID + +import httpx +import pytest + +# A client generated before the household report routes lacks this module. +pytest.importorskip( + "policyengine_api_simulation_client.api.default.get_household_report" +) + +from policyengine_api.libs.gateway_auth import GatewayAuthError +from policyengine_api.libs.simulation_household_reports import ( + SimulationEntrypointHouseholdReports, + household_reports_from_environment, + simulation_entrypoint_household_reports, +) +from policyengine_api.request_context import _asgi_request_id +from policyengine_api.services.v2.household_reports.types import ( + HouseholdCalculationError, + HouseholdCalculationOutput, + HouseholdCalculationRecord, + HouseholdPolicyChoice, + HouseholdReportRecord, + HouseholdReportRunRecord, + HouseholdReportSubmission, +) +from policyengine_api.services.v2.simulation_entrypoint.types import ( + ReportStatus, + RunTrigger, + SimulationRecordNotFoundError, + SimulationRequestRefusedError, + SimulationServiceUnavailableError, +) + +BASE_URL = "https://simulation.example.test" +REPORT_ID = UUID("00000000-0000-4000-8000-000000000201") +HOUSEHOLD_ID = UUID("00000000-0000-4000-8000-000000000202") +BASELINE_SIMULATION_ID = UUID("00000000-0000-4000-8000-000000000203") +REFORM_SIMULATION_ID = UUID("00000000-0000-4000-8000-000000000204") +REPORT_RUN_ID = UUID("00000000-0000-4000-8000-000000000205") +BASELINE_RUN_ID = UUID("00000000-0000-4000-8000-000000000206") +REFORM_RUN_ID = UUID("00000000-0000-4000-8000-000000000207") +CATALOG_VERSION_ID = UUID("00000000-0000-4000-8000-000000000208") +REFORM_POLICY_ID = UUID("00000000-0000-4000-8000-000000000209") +CREATED_AT = "2026-10-29T12:00:00Z" +CREATED_AT_VALUE = datetime(2026, 10, 29, 12, tzinfo=UTC) +COMPLETED_AT = "2026-10-29T12:00:09Z" +COMPLETED_AT_VALUE = datetime(2026, 10, 29, 12, 0, 9, tzinfo=UTC) +# Keys deliberately out of alphabetical order: the household function lists +# calculated values in the household's own key order. +CALCULATED_HOUSEHOLD = { + "people": {"you": {"age": {"2026": 40}, "employment_income": {"2026": 50000}}}, + "households": {"your household": {"members": ["you"]}}, +} +SPM_CONFIG = {"geography": "national"} +SPM_PROVENANCE = {"forecast": "2026-certified"} + + +def _calculation_body( + run_id: UUID, + simulation_id: UUID, + policy_id: UUID | None, + *, + status: str = "succeeded", + **changes, +) -> dict: + succeeded = status == "succeeded" + return { + "calculation_id": str(run_id), + "simulation_id": str(simulation_id), + "country": "us", + "household_id": str(HOUSEHOLD_ID), + "policy_id": None if policy_id is None else str(policy_id), + "options": {}, + "catalog_version_id": str(CATALOG_VERSION_ID), + "year": 2026, + "attempt": 1, + "trigger": "initial", + "status": status, + "created_at": CREATED_AT, + "dispatched_at": CREATED_AT if status != "pending" else None, + "completed_at": COMPLETED_AT if succeeded else None, + "result": ( + { + "household": CALCULATED_HOUSEHOLD, + "warnings": ["employment_income was capped"], + "spm_config": SPM_CONFIG, + "spm_provenance": SPM_PROVENANCE, + "bundle": {"policyengine_version": "6.7.5"}, + } + if succeeded + else None + ), + "error": None, + **changes, + } + + +def _report_body(**changes) -> dict: + return { + "report_id": str(REPORT_ID), + "country": "us", + "year": 2026, + "household_id": str(HOUSEHOLD_ID), + "baseline_simulation_id": str(BASELINE_SIMULATION_ID), + "reform_simulation_id": str(REFORM_SIMULATION_ID), + "report_run_id": str(REPORT_RUN_ID), + "catalog_version_id": str(CATALOG_VERSION_ID), + "trigger": "initial", + "status": "succeeded", + "baseline": _calculation_body(BASELINE_RUN_ID, BASELINE_SIMULATION_ID, None), + "reform": _calculation_body( + REFORM_RUN_ID, REFORM_SIMULATION_ID, REFORM_POLICY_ID + ), + **changes, + } + + +def _calculation_record( + run_id: UUID, simulation_id: UUID, policy_id: UUID | None +) -> HouseholdCalculationRecord: + return HouseholdCalculationRecord( + id=run_id, + simulation_id=simulation_id, + policy_id=policy_id, + status="succeeded", + attempt=1, + trigger="initial", + error=None, + result=HouseholdCalculationOutput( + household=CALCULATED_HOUSEHOLD, + warnings=("employment_income was capped",), + spm_config=SPM_CONFIG, + spm_provenance=SPM_PROVENANCE, + ), + dispatched_at=CREATED_AT_VALUE, + completed_at=COMPLETED_AT_VALUE, + created_at=CREATED_AT_VALUE, + ) + + +SUCCEEDED_REPORT = HouseholdReportRecord( + id=REPORT_ID, + country_id="us", + year=2026, + household_id=HOUSEHOLD_ID, + baseline_simulation_id=BASELINE_SIMULATION_ID, + reform_simulation_id=REFORM_SIMULATION_ID, + status=ReportStatus.SUCCEEDED, + run=HouseholdReportRunRecord( + id=REPORT_RUN_ID, + report_id=REPORT_ID, + trigger="initial", + tax_benefit_model_version_id=CATALOG_VERSION_ID, + policyengine_version=None, + year=2026, + status=ReportStatus.SUCCEEDED, + baseline=_calculation_record(BASELINE_RUN_ID, BASELINE_SIMULATION_ID, None), + reform=_calculation_record( + REFORM_RUN_ID, REFORM_SIMULATION_ID, REFORM_POLICY_ID + ), + ), +) + + +class SimulationEntrypointStub: + """Answer each request with the next queued response and record the request.""" + + def __init__(self, *responses: httpx.Response) -> None: + self.responses = list(responses) + self.requests: list[httpx.Request] = [] + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + return self.responses.pop(0) + + def adapter( + self, auth: httpx.Auth | None = None + ) -> SimulationEntrypointHouseholdReports: + return simulation_entrypoint_household_reports( + BASE_URL, auth=auth, transport=httpx.MockTransport(self) + ) + + +def _submission(*, reform: HouseholdPolicyChoice | None) -> HouseholdReportSubmission: + return HouseholdReportSubmission( + country_id="us", + policyengine_version="6.7.5", + household_id=HOUSEHOLD_ID, + baseline=HouseholdPolicyChoice(), + reform=reform, + ) + + +def test_submit_sends_the_household_and_policies_and_reads_the_new_report() -> None: + entrypoint = SimulationEntrypointStub(httpx.Response(202, json=_report_body())) + + report = entrypoint.adapter().submit_report( + _submission(reform=HouseholdPolicyChoice(policy_id=REFORM_POLICY_ID)) + ) + + (request,) = entrypoint.requests + assert request.method == "POST" + assert str(request.url) == f"{BASE_URL}/v2/household-reports" + assert json.loads(request.content) == { + "country": "us", + "policyengine_version": "6.7.5", + "household_id": str(HOUSEHOLD_ID), + "baseline": {"policy_id": None}, + "reform": {"policy_id": str(REFORM_POLICY_ID)}, + "options": {"add_missing_variables": False}, + "force": False, + "trigger": "initial", + } + assert report == SUCCEEDED_REPORT + + +def test_a_baseline_only_report_is_sent_without_a_reform() -> None: + entrypoint = SimulationEntrypointStub( + httpx.Response(202, json=_report_body(reform_simulation_id=None, reform=None)) + ) + + report = entrypoint.adapter().submit_report(_submission(reform=None)) + + assert json.loads(entrypoint.requests[0].content)["reform"] is None + assert report.reform_simulation_id is None + assert report.run is not None + assert report.run.reform is None + + +def test_a_calculated_household_keeps_the_key_order_it_was_sent_in() -> None: + entrypoint = SimulationEntrypointStub(httpx.Response(200, json=_report_body())) + + report = entrypoint.adapter().get_report(REPORT_ID) + + assert report.run is not None + calculated = report.run.baseline.result + assert calculated is not None + assert list(calculated.household) == ["people", "households"] + assert list(calculated.household["people"]["you"]) == ["age", "employment_income"] + + +def test_a_report_before_its_first_run_reads_as_not_run() -> None: + entrypoint = SimulationEntrypointStub( + httpx.Response( + 200, + json=_report_body( + household_id=None, + report_run_id=None, + catalog_version_id=None, + trigger=None, + status=None, + baseline=None, + reform=None, + ), + ) + ) + + report = entrypoint.adapter().get_report(REPORT_ID) + + assert str(entrypoint.requests[0].url) == ( + f"{BASE_URL}/v2/household-reports/{REPORT_ID}" + ) + assert report.status is ReportStatus.NOT_RUN + assert report.run is None + assert report.household_id is None + + +@pytest.mark.parametrize( + ("entrypoint_status", "report_status"), + [ + ("pending", ReportStatus.PENDING), + ("dispatching", ReportStatus.PENDING), + ("running", ReportStatus.RUNNING), + ], +) +def test_an_unfinished_run_reads_without_results( + entrypoint_status: str, report_status: ReportStatus +) -> None: + entrypoint = SimulationEntrypointStub( + httpx.Response( + 200, + json=_report_body( + status=entrypoint_status, + baseline=_calculation_body( + BASELINE_RUN_ID, + BASELINE_SIMULATION_ID, + None, + status=entrypoint_status, + ), + reform=_calculation_body( + REFORM_RUN_ID, + REFORM_SIMULATION_ID, + REFORM_POLICY_ID, + status=entrypoint_status, + ), + ), + ) + ) + + report = entrypoint.adapter().get_report(REPORT_ID) + + assert report.status is report_status + assert report.run is not None + assert report.run.status is report_status + assert report.run.baseline.status == entrypoint_status + assert report.run.baseline.result is None + + +def test_a_failed_calculation_reads_with_its_error() -> None: + failure = { + "code": "household_calculation_failed", + "message": "The household could not be calculated.", + "detail": {"errors": [{"code": "unknown_variable"}]}, + } + entrypoint = SimulationEntrypointStub( + httpx.Response( + 200, + json=_report_body( + status="failed", + reform=_calculation_body( + REFORM_RUN_ID, + REFORM_SIMULATION_ID, + REFORM_POLICY_ID, + status="failed", + error=failure, + ), + ), + ) + ) + + report = entrypoint.adapter().get_report(REPORT_ID) + + assert report.status is ReportStatus.FAILED + assert report.run is not None + assert report.run.reform is not None + assert report.run.reform.error == HouseholdCalculationError( + code="household_calculation_failed", + message="The household could not be calculated.", + detail={"errors": [{"code": "unknown_variable"}]}, + ) + + +def test_a_run_sends_its_version_trigger_and_force() -> None: + entrypoint = SimulationEntrypointStub( + httpx.Response(202, json=_report_body(trigger="manual", status="pending")) + ) + + report = entrypoint.adapter().run_report( + REPORT_ID, + policyengine_version="6.8.0", + trigger=RunTrigger.MANUAL, + force=True, + ) + + (request,) = entrypoint.requests + assert request.method == "POST" + assert str(request.url) == f"{BASE_URL}/v2/household-reports/{REPORT_ID}/runs" + assert json.loads(request.content) == { + "policyengine_version": "6.8.0", + "trigger": "manual", + "force": True, + } + assert report.run is not None + assert report.run.trigger == "manual" + + +def test_an_unknown_report_is_not_found() -> None: + entrypoint = SimulationEntrypointStub( + httpx.Response(404, json={"detail": "No such household report."}) + ) + + with pytest.raises(SimulationRecordNotFoundError): + entrypoint.adapter().get_report(REPORT_ID) + + +def test_a_run_of_an_unknown_report_is_not_found() -> None: + entrypoint = SimulationEntrypointStub( + httpx.Response(404, json={"detail": "No such household report."}) + ) + adapter = entrypoint.adapter() + + with pytest.raises(SimulationRecordNotFoundError): + adapter.run_report( + REPORT_ID, + policyengine_version="6.7.5", + trigger=RunTrigger.MANUAL, + force=False, + ) + + +def test_a_route_the_entrypoint_does_not_serve_means_it_is_unavailable() -> None: + entrypoint = SimulationEntrypointStub( + httpx.Response(404, json={"detail": "Not Found"}) + ) + + with pytest.raises(SimulationServiceUnavailableError): + entrypoint.adapter().get_report(REPORT_ID) + + +def test_a_refusal_of_the_callers_data_carries_the_entrypoint_reason() -> None: + reason = "The policy changes gov.irs.unknown, which catalog 6.7.5 lacks." + entrypoint = SimulationEntrypointStub(httpx.Response(422, json={"detail": reason})) + + with pytest.raises(SimulationRequestRefusedError) as raised: + entrypoint.adapter().submit_report(_submission(reform=None)) + + assert str(raised.value) == reason + + +def test_a_request_validation_refusal_names_each_field() -> None: + entrypoint = SimulationEntrypointStub( + httpx.Response( + 422, + json={ + "detail": [ + { + "loc": ["body", "household_id"], + "msg": "Input should be a valid UUID", + "type": "uuid_parsing", + } + ] + }, + ) + ) + + with pytest.raises(SimulationRequestRefusedError) as raised: + entrypoint.adapter().submit_report(_submission(reform=None)) + + assert str(raised.value) == "household_id: Input should be a valid UUID" + + +@pytest.mark.parametrize( + "response", + [ + httpx.Response(400, json={"detail": "No household 0000 in country us."}), + httpx.Response(422, json={"detail": {"unexpected": "shape"}}), + httpx.Response( + 503, json={"detail": "The v2 version manifest could not be read."} + ), + httpx.Response(500, text="Internal Server Error"), + httpx.Response(403, json={"detail": "Forbidden"}), + httpx.Response(202, json={"unexpected": "shape"}), + httpx.Response(202, text="not json"), + ], + ids=[ + "refused", + "malformed-refusal", + "unavailable", + "server-error", + "forbidden", + "shape", + "text", + ], +) +def test_an_unusable_answer_means_the_entrypoint_is_unavailable( + response: httpx.Response, +) -> None: + entrypoint = SimulationEntrypointStub(response) + + with pytest.raises(SimulationServiceUnavailableError): + entrypoint.adapter().submit_report(_submission(reform=None)) + + +def test_a_network_failure_means_the_entrypoint_is_unavailable() -> None: + def refuse(request: httpx.Request) -> httpx.Response: + message = "connection refused" + raise httpx.ConnectError(message, request=request) + + adapter = simulation_entrypoint_household_reports( + BASE_URL, auth=None, transport=httpx.MockTransport(refuse) + ) + + with pytest.raises(SimulationServiceUnavailableError): + adapter.get_report(REPORT_ID) + + +class FailingTokenAuth(httpx.Auth): + """Fail to fetch a token, as the gateway auth does when Auth0 cannot answer.""" + + def auth_flow(self, request: httpx.Request): + message = "Auth0 token fetch failed: HTTP 503" + raise GatewayAuthError(message) + yield request # pragma: no cover - makes this a generator, as httpx expects + + +def test_a_failed_token_fetch_means_the_entrypoint_is_unavailable() -> None: + entrypoint = SimulationEntrypointStub(httpx.Response(200, json=_report_body())) + + with pytest.raises(SimulationServiceUnavailableError) as raised: + entrypoint.adapter(auth=FailingTokenAuth()).get_report(REPORT_ID) + + assert "token" in str(raised.value) + assert entrypoint.requests == [] + + +def test_requests_carry_the_current_request_id() -> None: + entrypoint = SimulationEntrypointStub(httpx.Response(200, json=_report_body())) + token = _asgi_request_id.set("api-request-id") + try: + entrypoint.adapter().get_report(REPORT_ID) + finally: + _asgi_request_id.reset(token) + + assert entrypoint.requests[0].headers["X-PolicyEngine-Request-Id"] == ( + "api-request-id" + ) + + +def _clear_gateway_auth(monkeypatch: pytest.MonkeyPatch) -> None: + for name in ( + "GATEWAY_AUTH_ISSUER", + "GATEWAY_AUTH_AUDIENCE", + "GATEWAY_AUTH_CLIENT_ID", + "GATEWAY_AUTH_CLIENT_SECRET", + "GATEWAY_AUTH_CLIENT_SECRET_RESOURCE", + "GATEWAY_AUTH_REQUIRED", + ): + monkeypatch.delenv(name, raising=False) + + +def test_the_environment_names_the_entrypoint_url( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _clear_gateway_auth(monkeypatch) + monkeypatch.setenv("SIMULATION_ENTRYPOINT_URL", f"{BASE_URL}/") + monkeypatch.setenv("SIM_ENTRYPOINT", "old_gateway_direct") + + adapter = household_reports_from_environment() + + assert isinstance(adapter, SimulationEntrypointHouseholdReports) + assert adapter.base_url == BASE_URL + + +def test_an_unusable_entrypoint_url_makes_reports_unavailable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _clear_gateway_auth(monkeypatch) + monkeypatch.setenv("SIMULATION_ENTRYPOINT_URL", "") + + with pytest.raises(SimulationServiceUnavailableError) as raised: + household_reports_from_environment() + + assert "SIMULATION_ENTRYPOINT_URL" in str(raised.value) + + +def test_partial_gateway_auth_is_refused(monkeypatch: pytest.MonkeyPatch) -> None: + _clear_gateway_auth(monkeypatch) + monkeypatch.setenv("SIMULATION_ENTRYPOINT_URL", BASE_URL) + monkeypatch.setenv("GATEWAY_AUTH_ISSUER", "https://auth.example.test") + + with pytest.raises(SimulationServiceUnavailableError) as raised: + household_reports_from_environment() + + assert "partially configured" in str(raised.value) + + +@pytest.mark.parametrize( + "answer", + [ + _report_body(status="paused"), + _report_body(baseline={"calculation_id": str(BASELINE_RUN_ID)}), + _report_body( + baseline=_calculation_body( + BASELINE_RUN_ID, BASELINE_SIMULATION_ID, None, status="paused" + ) + ), + ], + ids=[ + "unknown-report-status", + "malformed-calculation", + "unknown-calculation-status", + ], +) +def test_a_success_answer_that_does_not_parse_means_the_entrypoint_is_unavailable( + answer: dict, +) -> None: + """The generated client keeps a nested value it cannot parse as it came.""" + entrypoint = SimulationEntrypointStub(httpx.Response(200, json=answer)) + + with pytest.raises(SimulationServiceUnavailableError): + entrypoint.adapter().get_report(REPORT_ID) + + +@pytest.mark.parametrize( + "answer", + [ + _report_body(household_id="not-a-uuid"), + _report_body(report_run_id="not-a-uuid"), + _report_body(catalog_version_id="not-a-uuid"), + _report_body(reform_simulation_id="not-a-uuid"), + _report_body(country=7), + _report_body( + baseline={ + **_calculation_body(BASELINE_RUN_ID, BASELINE_SIMULATION_ID, None), + "policy_id": "not-a-uuid", + } + ), + _report_body( + baseline=_calculation_body( + BASELINE_RUN_ID, BASELINE_SIMULATION_ID, None, dispatched_at="yesterday" + ) + ), + _report_body( + baseline={ + **_calculation_body(BASELINE_RUN_ID, BASELINE_SIMULATION_ID, None), + "result": { + "household": CALCULATED_HOUSEHOLD, + "warnings": [7], + "spm_config": None, + "spm_provenance": None, + "bundle": None, + }, + } + ), + ], + ids=[ + "household-id", + "report-run-id", + "catalog-version-id", + "reform-simulation-id", + "country", + "policy-id", + "dispatched-at", + "warnings", + ], +) +def test_a_value_the_client_passes_unchecked_means_the_entrypoint_is_unavailable( + answer: dict, +) -> None: + """The generated client copies some fields without checking their type.""" + entrypoint = SimulationEntrypointStub(httpx.Response(200, json=answer)) + + with pytest.raises(SimulationServiceUnavailableError): + entrypoint.adapter().get_report(REPORT_ID) + + +def test_a_well_formed_answer_passes_the_type_checks_unchanged() -> None: + failure = { + "code": "worker_timeout", + "message": "The call timed out.", + "detail": None, + } + answer = _report_body( + status="failed", + reform=_calculation_body( + REFORM_RUN_ID, + REFORM_SIMULATION_ID, + REFORM_POLICY_ID, + status="failed", + error=failure, + ), + ) + entrypoint = SimulationEntrypointStub(httpx.Response(200, json=answer)) + + report = entrypoint.adapter().get_report(REPORT_ID) + + assert report.run is not None + assert report.run.baseline == SUCCEEDED_REPORT.run.baseline + assert report.run.reform == HouseholdCalculationRecord( + id=REFORM_RUN_ID, + simulation_id=REFORM_SIMULATION_ID, + policy_id=REFORM_POLICY_ID, + status="failed", + attempt=1, + trigger="initial", + error=HouseholdCalculationError( + code="worker_timeout", message="The call timed out.", detail=None + ), + result=None, + dispatched_at=CREATED_AT_VALUE, + completed_at=None, + created_at=CREATED_AT_VALUE, + ) + + +def test_a_report_of_every_variable_asks_for_the_missing_ones() -> None: + entrypoint = SimulationEntrypointStub(httpx.Response(202, json=_report_body())) + submission = HouseholdReportSubmission( + country_id="us", + policyengine_version="6.7.5", + household_id=HOUSEHOLD_ID, + baseline=HouseholdPolicyChoice(), + reform=None, + add_missing_variables=True, + ) + + entrypoint.adapter().submit_report(submission) + + assert json.loads(entrypoint.requests[0].content)["options"] == { + "add_missing_variables": True + } diff --git a/tests/unit/test_alembic_workflows.py b/tests/unit/test_alembic_workflows.py index 5f3812f00..2445d0f7a 100644 --- a/tests/unit/test_alembic_workflows.py +++ b/tests/unit/test_alembic_workflows.py @@ -223,6 +223,7 @@ def test_reusable_v2_integration_check_uses_disposable_databases_and_coverage(): assert "test_v2_metadata_routes.py" in workflow assert "test_v2_country_routes.py" in workflow assert "test_v2_economy_reports.py" in workflow + assert "test_v2_household_reports.py" in workflow assert "test_v2_saved_reference_updates.py" in workflow assert "test_v2_policy_persistence.py" in workflow assert "test_v1_policy_dual_write.py" in workflow diff --git a/tests/unit/test_migration_contract_artifacts.py b/tests/unit/test_migration_contract_artifacts.py index 353ae639f..ce998a58e 100644 --- a/tests/unit/test_migration_contract_artifacts.py +++ b/tests/unit/test_migration_contract_artifacts.py @@ -13,8 +13,8 @@ def test_migration_contract_payload_summarizes_route_contracts(): assert payload["version"] == 1 assert payload["metadata"] == { "route_group_count": 9, - "workflow_count": 18, - "request_count": 96, + "workflow_count": 19, + "request_count": 99, "db_entity_count": 6, "sim_flow_count": 3, } @@ -33,6 +33,7 @@ def test_migration_contract_payload_summarizes_route_contracts(): "region_selection", "country_selection_v2", "economy_reports_v2", + "household_reports_v2", "metadata_resources_v2_preview", "simulation_submit_poll", "report_create_poll", diff --git a/tests/unit/v2/household_report_support.py b/tests/unit/v2/household_report_support.py new file mode 100644 index 000000000..452608ec3 --- /dev/null +++ b/tests/unit/v2/household_report_support.py @@ -0,0 +1,384 @@ +"""A fake Simulation Entrypoint, stored households and household report records. + +The household report service reads countries rows and catalog versions from an +SQLite v2 schema, and stored households and policies through fakes of the +``/v2/households`` and ``/v2/policies`` services. +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from datetime import UTC, datetime +from typing import TYPE_CHECKING +from uuid import NAMESPACE_URL, UUID, uuid5 + +from policyengine_api.data.v2.models import ( + Country, + TaxBenefitModel, + TaxBenefitModelVersion, +) +from policyengine_api.services.v2.household_reports.database_session import ( + HouseholdReportDatabaseSession, +) +from policyengine_api.services.v2.household_reports.services import ( + V2HouseholdReportService, +) +from policyengine_api.services.v2.household_reports.types import ( + HouseholdCalculationOutput, + HouseholdCalculationRecord, + HouseholdReportRecord, + HouseholdReportRunRecord, +) +from policyengine_api.services.v2.households.validators import HouseholdNotFoundError +from policyengine_api.services.v2.policies.validators import PolicyNotFoundError +from policyengine_api.services.v2.simulation_entrypoint.types import ( + ReportStatus, + RunTrigger, + SimulationRecordNotFoundError, +) +from tests.unit.v2.economy_report_support import ( + CURRENT_POLICYENGINE_VERSION, + countries_store, +) + +if TYPE_CHECKING: + from sqlalchemy.orm import sessionmaker + from sqlmodel import Session + + from policyengine_api.services.v2.household_reports.types import ( + HouseholdReportSubmission, + ) + +HOUSEHOLD_REPORT_ID = UUID("00000000-0000-4000-8000-000000000301") +HOUSEHOLD_ID = UUID("00000000-0000-4000-8000-000000000302") +HOUSEHOLD_REPORT_RUN_ID = UUID("00000000-0000-4000-8000-000000000303") +BASELINE_CALCULATION_ID = UUID("00000000-0000-4000-8000-000000000304") +REFORM_CALCULATION_ID = UUID("00000000-0000-4000-8000-000000000305") +HOUSEHOLD_CATALOG_VERSION_ID = UUID("00000000-0000-4000-8000-000000000306") +OLDER_CATALOG_VERSION_ID = UUID("00000000-0000-4000-8000-000000000307") +UNKNOWN_CATALOG_VERSION_ID = UUID("00000000-0000-4000-8000-000000000308") +HOUSEHOLD_REFORM_POLICY_ID = UUID("00000000-0000-4000-8000-000000000309") +# A household of the household-only test country. +HOUSEHOLD_ONLY_HOUSEHOLD_ID = UUID("00000000-0000-4000-8000-000000000310") +OLDER_POLICYENGINE_VERSION = "6.5.0" +CALCULATED_AT = datetime(2026, 10, 29, 12, tzinfo=UTC) +CALCULATED_HOUSEHOLD = { + "people": {"you": {"age": {"2026": 40}, "household_net_income": {"2026": 41250}}}, + "households": {"your household": {"members": ["you"]}}, +} + + +@dataclass(frozen=True) +class StoredHouseholdRow: + """The part of a stored household the report service reads.""" + + id: UUID + country_id: str + default_year: int | None + + +class FakeHouseholds: + """The ``/v2/households`` service, holding ``households``.""" + + def __init__(self, *households: StoredHouseholdRow) -> None: + self.households = {household.id: household for household in households} + + def get_household( + self, *, country_id: str, household_id: UUID + ) -> StoredHouseholdRow: + household = self.households.get(household_id) + if household is None or household.country_id != country_id: + msg = f"Household {household_id} was not found" + raise HouseholdNotFoundError(msg) + return household + + +class FakePolicies: + """The ``/v2/policies`` service, holding policies by ID and country.""" + + def __init__(self, policies: dict[UUID, str]) -> None: + self.policies = policies + + def get_policy(self, *, country_id: str, policy_id: UUID) -> object: + if self.policies.get(policy_id) != country_id: + msg = f"Policy {policy_id} was not found" + raise PolicyNotFoundError(msg) + return policy_id + + +def stored_household( + *, country_id: str = "us", default_year: int | None = 2026 +) -> StoredHouseholdRow: + return StoredHouseholdRow( + id=HOUSEHOLD_ID, country_id=country_id, default_year=default_year + ) + + +def calculation_record( + calculation_id: UUID, + simulation_id: UUID, + policy_id: UUID | None, + *, + status: str = "pending", + trigger: str = "initial", +) -> HouseholdCalculationRecord: + succeeded = status == "succeeded" + return HouseholdCalculationRecord( + id=calculation_id, + simulation_id=simulation_id, + policy_id=policy_id, + status=status, + attempt=1, + trigger=trigger, + error=None, + result=( + HouseholdCalculationOutput( + household=CALCULATED_HOUSEHOLD, + warnings=(), + spm_config=None, + spm_provenance=None, + ) + if succeeded + else None + ), + dispatched_at=None, + completed_at=CALCULATED_AT if succeeded else None, + created_at=CALCULATED_AT, + ) + + +def household_simulation_ids(report_id: UUID) -> tuple[UUID, UUID]: + """Return a household report's baseline and reform simulation IDs.""" + return ( + uuid5(NAMESPACE_URL, f"{report_id}/household-baseline"), + uuid5(NAMESPACE_URL, f"{report_id}/household-reform"), + ) + + +def household_report_record( + *, + report_id: UUID = HOUSEHOLD_REPORT_ID, + country_id: str = "us", + status: ReportStatus | None = ReportStatus.PENDING, + with_reform: bool = True, + trigger: str = "initial", +) -> HouseholdReportRecord: + """Build a household report; ``status=None`` means before its first run. + + Each calculation's status is the run's, with a result once it succeeded. + """ + baseline_id, reform_id = household_simulation_ids(report_id) + run = None + if status is not None: + calculation_status = ( + "running" if status is ReportStatus.RUNNING else status.value + ) + run = HouseholdReportRunRecord( + id=HOUSEHOLD_REPORT_RUN_ID, + report_id=report_id, + trigger=trigger, + tax_benefit_model_version_id=HOUSEHOLD_CATALOG_VERSION_ID, + policyengine_version=None, + year=2026, + status=status, + baseline=calculation_record( + BASELINE_CALCULATION_ID, + baseline_id, + None, + status=calculation_status, + trigger=trigger, + ), + reform=( + calculation_record( + REFORM_CALCULATION_ID, + reform_id, + HOUSEHOLD_REFORM_POLICY_ID, + status=calculation_status, + trigger=trigger, + ) + if with_reform + else None + ), + ) + return HouseholdReportRecord( + id=report_id, + country_id=country_id, + year=2026, + household_id=None if run is None else HOUSEHOLD_ID, + baseline_simulation_id=baseline_id, + reform_simulation_id=reform_id if with_reform else None, + status=ReportStatus.NOT_RUN if run is None else run.status, + run=run, + ) + + +def on_catalog_version( + report: HouseholdReportRecord, catalog_version_id: UUID +) -> HouseholdReportRecord: + """Return the report with its run on another catalog version.""" + if report.run is None: + return report + return replace( + report, + run=replace(report.run, tax_benefit_model_version_id=catalog_version_id), + ) + + +class FakeHouseholdReportSimulation: + """An in-memory Simulation Entrypoint for household reports. + + ``reports`` holds what it knows and ``calls`` records every call; ``error`` + is raised by every call while set. A submission or a run answers with + ``next_status`` and ``next_catalog_version_id``. + """ + + def __init__(self, *reports: HouseholdReportRecord) -> None: + self.reports = {report.id: report for report in reports} + self.calls: list[tuple[str, object]] = [] + self.error: Exception | None = None + self.next_status = ReportStatus.PENDING + self.next_catalog_version_id = HOUSEHOLD_CATALOG_VERSION_ID + + def _record(self, name: str, argument: object) -> None: + self.calls.append((name, argument)) + if self.error is not None: + raise self.error + + def _on_next_catalog_version( + self, report: HouseholdReportRecord + ) -> HouseholdReportRecord: + return on_catalog_version(report, self.next_catalog_version_id) + + def submit_report( + self, submission: HouseholdReportSubmission + ) -> HouseholdReportRecord: + self._record("submit_report", submission) + report = self._on_next_catalog_version( + household_report_record( + country_id=submission.country_id, + status=self.next_status, + with_reform=submission.reform is not None, + ) + ) + self.reports[report.id] = report + return report + + def get_report(self, report_id: UUID) -> HouseholdReportRecord: + self._record("get_report", report_id) + if report_id not in self.reports: + msg = "No such household report." + raise SimulationRecordNotFoundError(msg) + return self.reports[report_id] + + def run_report( + self, + report_id: UUID, + *, + policyengine_version: str, + trigger: RunTrigger, + force: bool, + ) -> HouseholdReportRecord: + self._record( + "run_report", + { + "report_id": report_id, + "policyengine_version": policyengine_version, + "trigger": trigger, + "force": force, + }, + ) + stored = self.reports.get(report_id) + if stored is None: + msg = "No such household report." + raise SimulationRecordNotFoundError(msg) + report = self._on_next_catalog_version( + household_report_record( + report_id=report_id, + country_id=stored.country_id, + status=self.next_status, + with_reform=stored.reform_simulation_id is not None, + trigger=trigger.value, + ) + ) + self.reports[report_id] = report + return report + + +def household_report_store() -> sessionmaker[Session]: + """Build the countries rows and two US catalog versions. + + Besides the US and UK, ``zz`` has only household calculations and ``xx`` + has neither household calculations nor economy reports. The catalog + versions are the current one and an older one. + """ + # New row objects: a row object another store committed is not inserted again. + sessions = countries_store( + Country(id="zz", household_enabled=True, society_wide_enabled=False), + Country(id="xx", household_enabled=False, society_wide_enabled=False), + ) + with sessions() as session: + model = TaxBenefitModel(name="policyengine-us") + session.add(model) + session.flush() + session.add_all( + TaxBenefitModelVersion( + id=version_id, + model_id=model.id, + version=version, + current_law_id=1, + metadata_time_periods=[2026], + ) + for version_id, version in ( + (HOUSEHOLD_CATALOG_VERSION_ID, CURRENT_POLICYENGINE_VERSION), + (OLDER_CATALOG_VERSION_ID, OLDER_POLICYENGINE_VERSION), + ) + ) + session.commit() + return sessions + + +def set_country_support( + sessions: sessionmaker[Session], + country_id: str, + *, + household_enabled: bool, + society_wide_enabled: bool, +) -> None: + """Change what a stored country supports, as for a household-only country.""" + with sessions() as session: + country = session.get(Country, country_id) + assert country is not None + country.household_enabled = household_enabled + country.society_wide_enabled = society_wide_enabled + session.commit() + + +def household_report_service( + simulation: FakeHouseholdReportSimulation, + *, + households: FakeHouseholds | None = None, + policies: FakePolicies | None = None, + sessions: sessionmaker[Session] | None = None, +) -> V2HouseholdReportService: + return V2HouseholdReportService( + HouseholdReportDatabaseSession( + household_report_store() if sessions is None else sessions + ), + households=( + FakeHouseholds( + stored_household(), + replace( + stored_household(country_id="zz"), id=HOUSEHOLD_ONLY_HOUSEHOLD_ID + ), + ) + if households is None + else households + ), + policies=( + FakePolicies({HOUSEHOLD_REFORM_POLICY_ID: "us"}) + if policies is None + else policies + ), + simulation=simulation, + current_policyengine_version=CURRENT_POLICYENGINE_VERSION, + ) diff --git a/tests/unit/v2/test_economy_report_routes.py b/tests/unit/v2/test_economy_report_routes.py index 1d5e7ed27..3dcfcf4ad 100644 --- a/tests/unit/v2/test_economy_report_routes.py +++ b/tests/unit/v2/test_economy_report_routes.py @@ -58,6 +58,10 @@ report_record, report_run_record, ) +from tests.unit.v2.household_report_support import ( + FakeHouseholdReportSimulation, + household_report_service, +) SUBMISSION = { "country_id": "us", @@ -83,12 +87,16 @@ def fallback(resource: str): return jsonify({"source": "flask", "resource": resource}) service = economy_report_service(simulation, sessions) + # A report that is not an economy report is looked for among household + # reports, of which this entrypoint has none. + household_service = household_report_service(FakeHouseholdReportSimulation()) dependencies = NativeRouteDependencies( readiness_probe=lambda: True, gateway_client_factory=lambda: None, metadata_reader_factory=lambda: None, specification_provider=dict, v2_economy_report_service_factory=service_factory or (lambda: service), + v2_household_report_service_factory=lambda: household_service, ) settings = RouteImplementationSettings( health=RouteImplementation.FLASK_FALLBACK, @@ -130,6 +138,7 @@ def _simulation_run_item(run_id, simulation_id) -> dict: PENDING_RUN_ITEM = { + "kind": "economy", "id": str(REPORT_RUN_ID), "report_id": str(REPORT_ID), "trigger": "initial", @@ -342,7 +351,9 @@ def test_openapi_publishes_the_submit_contract() -> None: } assert set(operation["responses"]) >= {"201", "400", "422", "503"} request_schema = operation["requestBody"]["content"]["application/json"]["schema"] - assert request_schema["$ref"].endswith("/EconomyReportCreateRequest") + assert request_schema["discriminator"]["mapping"]["economy"].endswith( + "/EconomyReportCreateRequest" + ) body = schema["components"]["schemas"]["EconomyReportCreateRequest"] assert set(body["required"]) == { "country_id", @@ -395,7 +406,7 @@ def test_a_report_outside_the_country_is_not_found(reports) -> None: assert response.status_code == 404 assert response.json() == { "status": "error", - "message": f"Economy report {REPORT_ID} was not found in country 'us'", + "message": f"Report {REPORT_ID} was not found in country 'us'", } @@ -698,7 +709,8 @@ def test_a_report_whose_result_cannot_be_read_back_is_a_bad_gateway() -> None: @pytest.mark.parametrize( ("method", "path", "statuses"), [ - ("post", "/v2/reports", {"201", "400", "413", "422", "500", "503"}), + # 404: a household report's household or policy is not the country's. + ("post", "/v2/reports", {"201", "400", "404", "413", "422", "500", "503"}), ("get", "/v2/reports", {"200", "422", "500", "503"}), ( "get", diff --git a/tests/unit/v2/test_household_report_defaults.py b/tests/unit/v2/test_household_report_defaults.py new file mode 100644 index 000000000..f5312a95f --- /dev/null +++ b/tests/unit/v2/test_household_report_defaults.py @@ -0,0 +1,39 @@ +"""The default household report adapter, when the generated client cannot serve it.""" + +from __future__ import annotations + +import sys +import types + +import pytest + +from policyengine_api.fastapi_routes.dependencies import ( + _default_household_report_simulation, +) +from policyengine_api.services.v2.simulation_entrypoint.types import ( + SimulationServiceUnavailableError, +) + +pytest.importorskip("policyengine_api_simulation_client") + + +def test_a_client_without_the_household_report_routes_is_unavailable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A client generated before the household report routes existed lacks them. + + Importing the adapter then fails with ``ImportError`` ("cannot import + name"), not ``ModuleNotFoundError``; it must still mean the simulation + service is unavailable, not an unexpected failure. + """ + monkeypatch.delitem( + sys.modules, "policyengine_api.libs.simulation_household_reports", raising=False + ) + monkeypatch.setitem( + sys.modules, + "policyengine_api_simulation_client.api.default", + types.ModuleType("policyengine_api_simulation_client.api.default"), + ) + + with pytest.raises(SimulationServiceUnavailableError, match="household report"): + _default_household_report_simulation() diff --git a/tests/unit/v2/test_household_report_routes.py b/tests/unit/v2/test_household_report_routes.py new file mode 100644 index 000000000..ca3c6e2c8 --- /dev/null +++ b/tests/unit/v2/test_household_report_routes.py @@ -0,0 +1,523 @@ +"""Route coverage for household reports at the native v2 report routes. + +The routes run on the real economy and household report services, with SQLite +countries and catalog rows, fake stored households and policies, and fake +Simulation Entrypoints. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from fastapi.testclient import TestClient +from flask import Flask, jsonify + +from policyengine_api.asgi_factory import create_asgi_app +from policyengine_api.fastapi_routes.dependencies import NativeRouteDependencies +from policyengine_api.migration_flags import ( + RouteImplementation, + RouteImplementationSettings, +) +from policyengine_api.services.v2.simulation_entrypoint.types import ( + ReportStatus, + RunTrigger, + SimulationRequestRefusedError, + SimulationServiceUnavailableError, +) +from tests.unit.v2.economy_report_support import ( + CURRENT_POLICYENGINE_VERSION, + FakeEconomyReportSimulation, + economy_report_service, +) +from tests.unit.v2.household_report_support import ( + BASELINE_CALCULATION_ID, + CALCULATED_AT, + CALCULATED_HOUSEHOLD, + HOUSEHOLD_CATALOG_VERSION_ID, + HOUSEHOLD_ID, + HOUSEHOLD_REFORM_POLICY_ID, + HOUSEHOLD_REPORT_ID, + HOUSEHOLD_REPORT_RUN_ID, + REFORM_CALCULATION_ID, + FakeHouseholdReportSimulation, + FakeHouseholds, + household_report_record, + household_report_service, + household_report_store, + household_simulation_ids, + set_country_support, + stored_household, +) + +if TYPE_CHECKING: + from uuid import UUID + + from sqlalchemy.orm import sessionmaker + from sqlmodel import Session + +SUBMISSION = { + "country_id": "us", + "kind": "household", + "household_id": str(HOUSEHOLD_ID), + "baseline": {}, + "reform": {"policy_id": str(HOUSEHOLD_REFORM_POLICY_ID)}, +} +CALCULATED_AT_TEXT = CALCULATED_AT.isoformat().replace("+00:00", "Z") +BASELINE_SIMULATION_ID, REFORM_SIMULATION_ID = household_simulation_ids( + HOUSEHOLD_REPORT_ID +) + + +def _client( + simulation: FakeHouseholdReportSimulation, + *, + economy_simulation: FakeEconomyReportSimulation | None = None, + households: FakeHouseholds | None = None, + sessions: sessionmaker[Session] | None = None, +) -> TestClient: + flask_app = Flask(__name__) + + @flask_app.route("/", methods=["GET", "POST"]) + def fallback(resource: str): + return jsonify({"source": "flask", "resource": resource}) + + sessions = household_report_store() if sessions is None else sessions + economy_service = economy_report_service( + economy_simulation or FakeEconomyReportSimulation(), sessions + ) + household_service = household_report_service( + simulation, households=households, sessions=sessions + ) + dependencies = NativeRouteDependencies( + readiness_probe=lambda: True, + gateway_client_factory=lambda: None, + metadata_reader_factory=lambda: None, + specification_provider=dict, + v2_economy_report_service_factory=lambda: economy_service, + v2_household_report_service_factory=lambda: household_service, + ) + settings = RouteImplementationSettings( + health=RouteImplementation.FLASK_FALLBACK, + specification=RouteImplementation.FLASK_FALLBACK, + metadata=RouteImplementation.FLASK_FALLBACK, + ) + return TestClient( + create_asgi_app(flask_app, dependencies=dependencies, route_settings=settings), + raise_server_exceptions=False, + ) + + +def _calculation_item(calculation_id: UUID, simulation_id: UUID, policy_id) -> dict: + return { + "id": str(calculation_id), + "simulation_id": str(simulation_id), + "policy_id": None if policy_id is None else str(policy_id), + "status": "pending", + "attempt": 1, + "trigger": "initial", + "error": None, + "result": None, + "dispatched_at": None, + "completed_at": None, + "created_at": CALCULATED_AT_TEXT, + } + + +PENDING_REPORT_ITEM = { + "id": str(HOUSEHOLD_REPORT_ID), + "country_id": "us", + "kind": "household", + "year": 2026, + "household_id": str(HOUSEHOLD_ID), + "baseline_simulation_id": str(BASELINE_SIMULATION_ID), + "reform_simulation_id": str(REFORM_SIMULATION_ID), + "status": "pending", + "run": { + "kind": "household", + "id": str(HOUSEHOLD_REPORT_RUN_ID), + "report_id": str(HOUSEHOLD_REPORT_ID), + "trigger": "initial", + "tax_benefit_model_version_id": str(HOUSEHOLD_CATALOG_VERSION_ID), + "policyengine_version": CURRENT_POLICYENGINE_VERSION, + "year": 2026, + "status": "pending", + "baseline": _calculation_item( + BASELINE_CALCULATION_ID, BASELINE_SIMULATION_ID, None + ), + "reform": _calculation_item( + REFORM_CALCULATION_ID, REFORM_SIMULATION_ID, HOUSEHOLD_REFORM_POLICY_ID + ), + }, +} + + +def test_a_household_report_is_submitted_and_answered_with_its_first_run() -> None: + simulation = FakeHouseholdReportSimulation() + + response = _client(simulation).post("/v2/reports?country_id=us", json=SUBMISSION) + + assert response.status_code == 201, response.text + assert response.json() == { + "status": "ok", + "message": None, + "result": {"item": PENDING_REPORT_ITEM}, + } + assert [name for name, _argument in simulation.calls] == ["submit_report"] + + +def test_a_succeeded_household_report_carries_each_calculated_household() -> None: + simulation = FakeHouseholdReportSimulation() + simulation.next_status = ReportStatus.SUCCEEDED + + response = _client(simulation).post("/v2/reports?country_id=us", json=SUBMISSION) + + run = response.json()["result"]["item"]["run"] + assert run["status"] == "succeeded" + assert run["baseline"]["result"] == { + "household": CALCULATED_HOUSEHOLD, + "warnings": [], + "spm_config": None, + "spm_provenance": None, + } + assert run["reform"]["result"]["household"] == CALCULATED_HOUSEHOLD + + +def test_a_baseline_only_household_report_has_no_reform() -> None: + simulation = FakeHouseholdReportSimulation() + + response = _client(simulation).post( + "/v2/reports?country_id=us", json={**SUBMISSION, "reform": None} + ) + + assert response.status_code == 201, response.text + item = response.json()["result"]["item"] + assert item["reform_simulation_id"] is None + assert item["run"]["reform"] is None + + +def test_an_economy_submission_still_reaches_the_economy_service() -> None: + simulation = FakeHouseholdReportSimulation() + economy_simulation = FakeEconomyReportSimulation() + + response = _client(simulation, economy_simulation=economy_simulation).post( + "/v2/reports?country_id=us", + json={ + "country_id": "us", + "kind": "economy", + "year": 2026, + "region_code": "state/ca", + "baseline": {}, + "reform": {}, + }, + ) + + assert response.status_code == 201, response.text + assert response.json()["result"]["item"]["kind"] == "economy" + assert simulation.calls == [] + assert [name for name, _argument in economy_simulation.calls] == ["submit_report"] + + +def test_a_household_only_country_accepts_household_reports() -> None: + sessions = household_report_store() + set_country_support( + sessions, "us", household_enabled=True, society_wide_enabled=False + ) + simulation = FakeHouseholdReportSimulation() + + response = _client(simulation, sessions=sessions).post( + "/v2/reports?country_id=us", json=SUBMISSION + ) + + assert response.status_code == 201, response.text + assert [name for name, _argument in simulation.calls] == ["submit_report"] + + +def test_a_country_without_household_calculations_is_refused_with_422() -> None: + sessions = household_report_store() + set_country_support( + sessions, "us", household_enabled=False, society_wide_enabled=False + ) + simulation = FakeHouseholdReportSimulation() + + response = _client(simulation, sessions=sessions).post( + "/v2/reports?country_id=us", json=SUBMISSION + ) + + assert response.status_code == 422, response.text + assert response.json()["message"] == ( + "Household reports are not available for country 'us'" + ) + assert simulation.calls == [] + + +@pytest.mark.parametrize( + ("changes", "households"), + [ + ({"household_id": "00000000-0000-4000-8000-0000000003ff"}, None), + ({"reform": {"policy_id": "00000000-0000-4000-8000-0000000003ff"}}, None), + ({}, FakeHouseholds(stored_household(country_id="uk"))), + ], + ids=["unknown-household", "unknown-policy", "other-countrys-household"], +) +def test_a_household_or_policy_the_country_lacks_is_not_found( + changes: dict, households: FakeHouseholds | None +) -> None: + simulation = FakeHouseholdReportSimulation() + + response = _client(simulation, households=households).post( + "/v2/reports?country_id=us", json={**SUBMISSION, **changes} + ) + + assert response.status_code == 404, response.text + assert simulation.calls == [] + + +def test_a_household_without_a_default_year_is_refused_with_422() -> None: + simulation = FakeHouseholdReportSimulation() + + response = _client( + simulation, households=FakeHouseholds(stored_household(default_year=None)) + ).post("/v2/reports?country_id=us", json=SUBMISSION) + + assert response.status_code == 422, response.text + assert "default year" in response.json()["message"] + assert simulation.calls == [] + + +def test_the_entrypoint_refusal_of_the_callers_data_is_422_with_its_reason() -> None: + simulation = FakeHouseholdReportSimulation() + simulation.error = SimulationRequestRefusedError( + "The policy changes gov.x, which the catalog version lacks." + ) + + response = _client(simulation).post("/v2/reports?country_id=us", json=SUBMISSION) + + assert response.status_code == 422, response.text + assert response.json()["message"] == ( + "The policy changes gov.x, which the catalog version lacks." + ) + + +def test_an_unavailable_entrypoint_is_503() -> None: + simulation = FakeHouseholdReportSimulation() + simulation.error = SimulationServiceUnavailableError("it answered HTTP 400") + + response = _client(simulation).post("/v2/reports?country_id=us", json=SUBMISSION) + + assert response.status_code == 503, response.text + assert response.json()["message"] == "The simulation service is unavailable" + + +@pytest.mark.parametrize( + "changes", + [ + {"year": 2026}, + {"options": {"axes": []}}, + {"baseline": {"dynamic_id": str(HOUSEHOLD_ID)}}, + {"household_id": "not-a-uuid"}, + ], + ids=["year", "options", "dynamic", "household-id"], +) +def test_fields_outside_the_household_report_contract_are_refused( + changes: dict, +) -> None: + simulation = FakeHouseholdReportSimulation() + + response = _client(simulation).post( + "/v2/reports?country_id=us", json={**SUBMISSION, **changes} + ) + + assert response.status_code == 422, response.text + assert simulation.calls == [] + + +def test_the_body_and_query_must_name_the_same_country() -> None: + simulation = FakeHouseholdReportSimulation() + + response = _client(simulation).post("/v2/reports?country_id=uk", json=SUBMISSION) + + assert response.status_code == 400, response.text + assert simulation.calls == [] + + +def test_openapi_describes_both_kinds_of_report() -> None: + schema = _client(FakeHouseholdReportSimulation()).get("/v2/openapi.json").json() + + body_schema = schema["paths"]["/v2/reports"]["post"]["requestBody"]["content"][ + "application/json" + ]["schema"] + assert body_schema["discriminator"]["propertyName"] == "kind" + assert set(body_schema["discriminator"]["mapping"]) == {"economy", "household"} + item_schema = schema["components"]["schemas"]["ReportDetailResult"]["properties"][ + "item" + ] + assert set(item_schema["discriminator"]["mapping"]) == {"economy", "household"} + + +def test_a_household_report_is_read_by_its_id() -> None: + simulation = FakeHouseholdReportSimulation(household_report_record()) + + response = _client(simulation).get( + f"/v2/reports/{HOUSEHOLD_REPORT_ID}?country_id=us" + ) + + assert response.status_code == 200, response.text + assert response.json()["result"]["item"] == PENDING_REPORT_ITEM + + +def test_a_report_of_neither_kind_is_404() -> None: + response = _client(FakeHouseholdReportSimulation()).get( + f"/v2/reports/{HOUSEHOLD_REPORT_ID}?country_id=us" + ) + + assert response.status_code == 404, response.text + assert response.json()["message"] == ( + f"Report {HOUSEHOLD_REPORT_ID} was not found in country 'us'" + ) + + +def test_another_countrys_household_report_is_404() -> None: + simulation = FakeHouseholdReportSimulation(household_report_record(country_id="uk")) + + response = _client(simulation).get( + f"/v2/reports/{HOUSEHOLD_REPORT_ID}?country_id=us" + ) + + assert response.status_code == 404, response.text + + +def test_a_listing_summarizes_household_reports_without_their_calculations() -> None: + simulation = FakeHouseholdReportSimulation( + household_report_record(status=ReportStatus.SUCCEEDED) + ) + + response = _client(simulation).get( + f"/v2/reports?country_id=us&report_id={HOUSEHOLD_REPORT_ID}" + ) + + assert response.status_code == 200, response.text + (item,) = response.json()["result"]["items"] + assert item["kind"] == "household" + assert item["status"] == "succeeded" + assert item["run"] == { + "kind": "household", + "id": str(HOUSEHOLD_REPORT_RUN_ID), + "report_id": str(HOUSEHOLD_REPORT_ID), + "trigger": "initial", + "tax_benefit_model_version_id": str(HOUSEHOLD_CATALOG_VERSION_ID), + "policyengine_version": CURRENT_POLICYENGINE_VERSION, + "year": 2026, + "status": "succeeded", + } + + +def test_openapi_lists_both_kinds_of_report_summary() -> None: + schema = _client(FakeHouseholdReportSimulation()).get("/v2/openapi.json").json() + + items_schema = schema["components"]["schemas"]["ReportListResult"]["properties"][ + "items" + ]["items"] + assert set(items_schema["discriminator"]["mapping"]) == {"economy", "household"} + + +@pytest.mark.parametrize("body", [{}, {"force": True}], ids=["reuse", "force"]) +def test_a_household_report_is_run_again_and_answered_with_the_new_run( + body: dict, +) -> None: + simulation = FakeHouseholdReportSimulation( + household_report_record(status=ReportStatus.SUCCEEDED) + ) + + response = _client(simulation).post( + f"/v2/reports/{HOUSEHOLD_REPORT_ID}/runs?country_id=us", json=body + ) + + assert response.status_code == 201, response.text + item = response.json()["result"]["item"] + assert item["kind"] == "household" + assert item["trigger"] == "manual" + assert item["status"] == "pending" + assert item["baseline"]["trigger"] == "manual" + assert simulation.calls[-1] == ( + "run_report", + { + "report_id": HOUSEHOLD_REPORT_ID, + "policyengine_version": CURRENT_POLICYENGINE_VERSION, + "trigger": RunTrigger.MANUAL, + "force": body.get("force", False), + }, + ) + + +def test_a_rerun_of_neither_kind_is_404() -> None: + response = _client(FakeHouseholdReportSimulation()).post( + f"/v2/reports/{HOUSEHOLD_REPORT_ID}/runs?country_id=us", json={} + ) + + assert response.status_code == 404, response.text + assert response.json()["message"] == ( + f"Report {HOUSEHOLD_REPORT_ID} was not found in country 'us'" + ) + + +def test_openapi_describes_both_kinds_of_run() -> None: + schema = _client(FakeHouseholdReportSimulation()).get("/v2/openapi.json").json() + + item_schema = schema["components"]["schemas"]["ReportRunDetailResult"][ + "properties" + ]["item"] + assert set(item_schema["discriminator"]["mapping"]) == {"economy", "household"} + + +def test_household_report_schemas_are_named_for_household_reports() -> None: + """Name the report's calculations for household reports. + + The household calculation routes have their own ``HouseholdCalculation*`` + schemas, so both sets can be served side by side. + """ + schemas = ( + _client(FakeHouseholdReportSimulation()) + .get("/v2/openapi.json") + .json()["components"]["schemas"] + ) + + assert { + "HouseholdReportCalculationItem", + "HouseholdReportCalculationErrorItem", + "HouseholdReportCalculationResultItem", + } <= set(schemas) + assert not {name for name in schemas if name.startswith("HouseholdCalculation")} + + +def test_openapi_says_household_reports_do_not_list_their_runs() -> None: + schema = _client(FakeHouseholdReportSimulation()).get("/v2/openapi.json").json() + + not_found = schema["paths"]["/v2/reports/{report_id}/runs"]["get"]["responses"][ + "404" + ]["description"] + assert "household report" in not_found + + +def test_openapi_describes_a_rerun_of_either_kind() -> None: + schema = _client(FakeHouseholdReportSimulation()).get("/v2/openapi.json").json() + + run_request = schema["components"]["schemas"]["EconomyReportRunRequest"] + assert "household report" in run_request["description"] + + +@pytest.mark.parametrize( + ("value", "status"), [(True, 201), (False, 201), ("yes", 422), (None, 422)] +) +def test_a_household_report_may_ask_for_every_variable(value, status: int) -> None: + simulation = FakeHouseholdReportSimulation() + + response = _client(simulation).post( + "/v2/reports?country_id=us", + json={**SUBMISSION, "add_missing_variables": value}, + ) + + assert response.status_code == status, response.text + if status == 201: + ((_name, submission),) = simulation.calls + assert submission.add_missing_variables is value diff --git a/tests/unit/v2/test_household_report_service.py b/tests/unit/v2/test_household_report_service.py new file mode 100644 index 000000000..801cedd3a --- /dev/null +++ b/tests/unit/v2/test_household_report_service.py @@ -0,0 +1,416 @@ +"""The household report service. + +It runs with SQLite countries and catalog rows, fake stored households and +policies, and a fake Simulation Entrypoint. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from typing import TYPE_CHECKING +from uuid import UUID + +import pytest + +from policyengine_api.services.v2.household_reports.database_session import ( + HouseholdReportDatabaseSession, +) +from policyengine_api.services.v2.household_reports.services import ( + V2HouseholdReportService, +) +from policyengine_api.services.v2.household_reports.types import ( + HouseholdPolicyChoice, + HouseholdReportFields, + HouseholdReportSubmission, +) +from policyengine_api.services.v2.household_reports.validators import ( + HouseholdReportNotFoundError, + HouseholdReportsUnsupportedError, + HouseholdReportYearError, +) +from policyengine_api.services.v2.households.validators import HouseholdNotFoundError +from policyengine_api.services.v2.policies.validators import PolicyNotFoundError +from policyengine_api.services.v2.simulation_entrypoint.types import ( + ReportStatus, + RunTrigger, + SimulationRequestRefusedError, +) +from tests.unit.v2.economy_report_support import CURRENT_POLICYENGINE_VERSION +from tests.unit.v2.household_report_support import ( + HOUSEHOLD_ID, + HOUSEHOLD_ONLY_HOUSEHOLD_ID, + HOUSEHOLD_REFORM_POLICY_ID, + HOUSEHOLD_REPORT_ID, + OLDER_CATALOG_VERSION_ID, + OLDER_POLICYENGINE_VERSION, + UNKNOWN_CATALOG_VERSION_ID, + FakeHouseholdReportSimulation, + FakeHouseholds, + FakePolicies, + household_report_record, + household_report_service, + household_report_store, + on_catalog_version, + stored_household, +) + +if TYPE_CHECKING: + from collections.abc import Iterator + + from sqlmodel import Session + + +def _fields(*, reform_policy_id=HOUSEHOLD_REFORM_POLICY_ID, household_id=HOUSEHOLD_ID): + return HouseholdReportFields( + kind="household", + household_id=household_id, + baseline=HouseholdPolicyChoice(), + reform=( + None + if reform_policy_id is None + else HouseholdPolicyChoice(policy_id=reform_policy_id) + ), + ) + + +def test_a_report_is_submitted_by_ids_on_the_current_version() -> None: + simulation = FakeHouseholdReportSimulation() + + report = household_report_service(simulation).submit_report( + country_id="us", report_input=_fields() + ) + + assert simulation.calls == [ + ( + "submit_report", + HouseholdReportSubmission( + country_id="us", + policyengine_version=CURRENT_POLICYENGINE_VERSION, + household_id=HOUSEHOLD_ID, + baseline=HouseholdPolicyChoice(), + reform=HouseholdPolicyChoice(policy_id=HOUSEHOLD_REFORM_POLICY_ID), + add_missing_variables=False, + ), + ) + ] + assert report.status is ReportStatus.PENDING + assert report.run is not None + assert report.run.policyengine_version == CURRENT_POLICYENGINE_VERSION + + +class CountingDatabaseSession(HouseholdReportDatabaseSession): + """Count the read sessions the service opens.""" + + def __init__(self) -> None: + super().__init__(household_report_store()) + self.reads = 0 + + @contextmanager + def read(self) -> Iterator[Session]: + self.reads += 1 + with super().read() as session: + yield session + + +def _service_on( + database_session: HouseholdReportDatabaseSession, + simulation: FakeHouseholdReportSimulation, +) -> V2HouseholdReportService: + return V2HouseholdReportService( + database_session, + households=FakeHouseholds(stored_household()), + policies=FakePolicies({HOUSEHOLD_REFORM_POLICY_ID: "us"}), + simulation=simulation, + current_policyengine_version=CURRENT_POLICYENGINE_VERSION, + ) + + +def test_a_submission_names_the_version_it_sent_without_reading_the_catalog() -> None: + """The first run is on the catalog version of the version sent. + + So once the entrypoint has created the report, no database read can fail + the answer. + """ + database_session = CountingDatabaseSession() + simulation = FakeHouseholdReportSimulation() + simulation.next_catalog_version_id = UNKNOWN_CATALOG_VERSION_ID + + report = _service_on(database_session, simulation).submit_report( + country_id="us", report_input=_fields() + ) + + assert report.run is not None + assert report.run.policyengine_version == CURRENT_POLICYENGINE_VERSION + # Only the countries row, before the entrypoint is asked. + assert database_session.reads == 1 + + +def test_a_read_run_names_the_policyengine_version_of_its_catalog_version() -> None: + simulation = FakeHouseholdReportSimulation( + on_catalog_version(household_report_record(), OLDER_CATALOG_VERSION_ID) + ) + + report = household_report_service(simulation).get_report( + country_id="us", report_id=HOUSEHOLD_REPORT_ID + ) + + assert report.run is not None + assert report.run.policyengine_version == OLDER_POLICYENGINE_VERSION + + +def test_a_read_run_on_a_catalog_version_the_public_api_lacks_names_no_version() -> ( + None +): + simulation = FakeHouseholdReportSimulation( + on_catalog_version(household_report_record(), UNKNOWN_CATALOG_VERSION_ID) + ) + + report = household_report_service(simulation).get_report( + country_id="us", report_id=HOUSEHOLD_REPORT_ID + ) + + assert report.run is not None + assert report.run.policyengine_version is None + + +def test_a_baseline_only_report_sends_no_reform() -> None: + simulation = FakeHouseholdReportSimulation() + + report = household_report_service(simulation).submit_report( + country_id="us", report_input=_fields(reform_policy_id=None) + ) + + ((_name, submission),) = simulation.calls + assert isinstance(submission, HouseholdReportSubmission) + assert submission.reform is None + assert report.reform_simulation_id is None + + +def test_a_household_only_country_has_household_reports() -> None: + simulation = FakeHouseholdReportSimulation() + + report = household_report_service(simulation).submit_report( + country_id="zz", + report_input=_fields( + reform_policy_id=None, household_id=HOUSEHOLD_ONLY_HOUSEHOLD_ID + ), + ) + + assert report.country_id == "zz" + assert [name for name, _argument in simulation.calls] == ["submit_report"] + + +def test_a_country_without_household_calculations_is_refused_first() -> None: + simulation = FakeHouseholdReportSimulation() + + with pytest.raises(HouseholdReportsUnsupportedError, match="'xx'"): + household_report_service(simulation).submit_report( + country_id="xx", report_input=_fields() + ) + + assert simulation.calls == [] + + +def test_an_unknown_household_is_not_found_before_the_entrypoint() -> None: + simulation = FakeHouseholdReportSimulation() + + with pytest.raises(HouseholdNotFoundError): + household_report_service(simulation, households=FakeHouseholds()).submit_report( + country_id="us", report_input=_fields() + ) + + assert simulation.calls == [] + + +def test_another_countrys_household_is_not_found() -> None: + simulation = FakeHouseholdReportSimulation() + + with pytest.raises(HouseholdNotFoundError): + household_report_service(simulation).submit_report( + country_id="uk", report_input=_fields(reform_policy_id=None) + ) + + assert simulation.calls == [] + + +def test_an_unknown_policy_is_not_found_before_the_entrypoint() -> None: + simulation = FakeHouseholdReportSimulation() + + with pytest.raises(PolicyNotFoundError): + household_report_service(simulation).submit_report( + country_id="us", + report_input=HouseholdReportFields( + kind="household", + household_id=HOUSEHOLD_ID, + baseline=HouseholdPolicyChoice(policy_id=HOUSEHOLD_ID), + reform=None, + ), + ) + + assert simulation.calls == [] + + +def test_a_household_without_a_default_year_is_refused_before_the_entrypoint() -> None: + simulation = FakeHouseholdReportSimulation() + households = FakeHouseholds(stored_household(default_year=None)) + + with pytest.raises(HouseholdReportYearError): + household_report_service(simulation, households=households).submit_report( + country_id="us", report_input=_fields() + ) + + assert simulation.calls == [] + + +def test_the_entrypoint_refusal_reaches_the_caller_unchanged() -> None: + simulation = FakeHouseholdReportSimulation() + simulation.error = SimulationRequestRefusedError("gov.x is not in this catalog") + + with pytest.raises(SimulationRequestRefusedError, match=r"gov\.x"): + household_report_service(simulation).submit_report( + country_id="us", report_input=_fields() + ) + + +def test_a_report_is_read_with_its_runs_policyengine_version() -> None: + simulation = FakeHouseholdReportSimulation( + household_report_record(status=ReportStatus.SUCCEEDED) + ) + + report = household_report_service(simulation).get_report( + country_id="us", report_id=HOUSEHOLD_REPORT_ID + ) + + assert report.status is ReportStatus.SUCCEEDED + assert report.run is not None + assert report.run.policyengine_version == CURRENT_POLICYENGINE_VERSION + assert simulation.calls == [("get_report", HOUSEHOLD_REPORT_ID)] + + +def test_a_report_before_its_first_run_is_read_without_a_catalog_read() -> None: + simulation = FakeHouseholdReportSimulation(household_report_record(status=None)) + + report = household_report_service(simulation).get_report( + country_id="us", report_id=HOUSEHOLD_REPORT_ID + ) + + assert report.status is ReportStatus.NOT_RUN + assert report.run is None + + +@pytest.mark.parametrize( + "reports", + [(), (household_report_record(country_id="uk"),)], + ids=["unknown", "other-country"], +) +def test_a_report_the_country_lacks_is_not_found(reports) -> None: + simulation = FakeHouseholdReportSimulation(*reports) + + with pytest.raises(HouseholdReportNotFoundError): + household_report_service(simulation).get_report( + country_id="us", report_id=HOUSEHOLD_REPORT_ID + ) + + +def test_reports_are_listed_in_the_order_asked_once_each() -> None: + second_report_id = UUID("00000000-0000-4000-8000-0000000003a1") + other_country_report_id = UUID("00000000-0000-4000-8000-0000000003a2") + unknown_report_id = UUID("00000000-0000-4000-8000-0000000003a3") + simulation = FakeHouseholdReportSimulation( + household_report_record(), + household_report_record(report_id=second_report_id, status=None), + household_report_record(report_id=other_country_report_id, country_id="uk"), + ) + + reports = household_report_service(simulation).list_reports( + country_id="us", + report_ids=[ + second_report_id, + unknown_report_id, + HOUSEHOLD_REPORT_ID, + other_country_report_id, + second_report_id, + ], + ) + + assert [report.id for report in reports] == [second_report_id, HOUSEHOLD_REPORT_ID] + assert reports[1].run is not None + assert reports[1].run.policyengine_version == CURRENT_POLICYENGINE_VERSION + + +@pytest.mark.parametrize("force", [False, True]) +def test_a_rerun_runs_on_the_current_version_and_answers_the_new_run( + *, force: bool +) -> None: + simulation = FakeHouseholdReportSimulation( + household_report_record(status=ReportStatus.SUCCEEDED) + ) + database_session = CountingDatabaseSession() + + run = _service_on(database_session, simulation).run_report( + country_id="us", report_id=HOUSEHOLD_REPORT_ID, force=force + ) + + assert simulation.calls == [ + ("get_report", HOUSEHOLD_REPORT_ID), + ( + "run_report", + { + "report_id": HOUSEHOLD_REPORT_ID, + "policyengine_version": CURRENT_POLICYENGINE_VERSION, + "trigger": RunTrigger.MANUAL, + "force": force, + }, + ), + ] + assert run.trigger == "manual" + assert run.status is ReportStatus.PENDING + assert run.policyengine_version == CURRENT_POLICYENGINE_VERSION + # Only the countries row, before the entrypoint is asked to run it. + assert database_session.reads == 1 + + +@pytest.mark.parametrize( + "reports", + [(), (household_report_record(country_id="uk"),)], + ids=["unknown", "other-country"], +) +def test_a_rerun_of_a_report_the_country_lacks_is_not_found(reports) -> None: + simulation = FakeHouseholdReportSimulation(*reports) + + with pytest.raises(HouseholdReportNotFoundError): + household_report_service(simulation).run_report( + country_id="us", report_id=HOUSEHOLD_REPORT_ID, force=False + ) + + assert [name for name, _argument in simulation.calls] == ["get_report"] + + +def test_a_rerun_in_a_country_without_household_calculations_is_refused_first() -> None: + simulation = FakeHouseholdReportSimulation(household_report_record(country_id="xx")) + + with pytest.raises(HouseholdReportsUnsupportedError, match="'xx'"): + household_report_service(simulation).run_report( + country_id="xx", report_id=HOUSEHOLD_REPORT_ID, force=False + ) + + assert simulation.calls == [] + + +def test_a_report_can_ask_for_every_variable_the_household_lacks() -> None: + simulation = FakeHouseholdReportSimulation() + + household_report_service(simulation).submit_report( + country_id="us", + report_input=HouseholdReportFields( + kind="household", + household_id=HOUSEHOLD_ID, + baseline=HouseholdPolicyChoice(), + reform=None, + add_missing_variables=True, + ), + ) + + ((_name, submission),) = simulation.calls + assert isinstance(submission, HouseholdReportSubmission) + assert submission.add_missing_variables is True diff --git a/tests/unit/v2/test_report_kinds_service.py b/tests/unit/v2/test_report_kinds_service.py new file mode 100644 index 000000000..b61c6565c --- /dev/null +++ b/tests/unit/v2/test_report_kinds_service.py @@ -0,0 +1,283 @@ +"""Which kind's service answers each /v2/reports operation.""" + +from __future__ import annotations + +from uuid import UUID + +import pytest + +from policyengine_api.services.v2.economy_reports.validators import ( + EconomyReportsUnsupportedError, +) +from policyengine_api.services.v2.report_kinds.services import V2ReportService +from policyengine_api.services.v2.report_kinds.validators import ReportNotFoundError +from policyengine_api.services.v2.simulation_entrypoint.types import ( + SimulationServiceUnavailableError, +) +from tests.unit.v2.economy_report_support import ( + REPORT_ID, + FakeEconomyReportSimulation, + economy_report_service, + report_record, + report_run_record, +) +from tests.unit.v2.household_report_support import ( + HOUSEHOLD_REPORT_ID, + FakeHouseholdReportSimulation, + household_report_record, + household_report_service, + household_report_store, + set_country_support, +) + +UNKNOWN_REPORT_ID = UUID("00000000-0000-4000-8000-0000000004ff") + + +def _service( + economy_simulation: FakeEconomyReportSimulation, + household_simulation: FakeHouseholdReportSimulation, +) -> V2ReportService: + sessions = household_report_store() + return V2ReportService( + economy_reports=lambda: economy_report_service(economy_simulation, sessions), + household_reports=lambda: household_report_service( + household_simulation, sessions=sessions + ), + ) + + +def test_an_economy_report_is_read_without_asking_for_a_household_report() -> None: + economy_simulation = FakeEconomyReportSimulation( + report_record(run=report_run_record()) + ) + household_simulation = FakeHouseholdReportSimulation() + + report = _service(economy_simulation, household_simulation).get_report( + country_id="us", report_id=REPORT_ID + ) + + assert report.id == REPORT_ID + assert household_simulation.calls == [] + + +def test_a_household_report_is_read_once_no_economy_report_has_its_id() -> None: + economy_simulation = FakeEconomyReportSimulation() + household_simulation = FakeHouseholdReportSimulation(household_report_record()) + + report = _service(economy_simulation, household_simulation).get_report( + country_id="us", report_id=HOUSEHOLD_REPORT_ID + ) + + assert report.id == HOUSEHOLD_REPORT_ID + assert [name for name, _argument in economy_simulation.calls] == ["get_report"] + assert household_simulation.calls == [("get_report", HOUSEHOLD_REPORT_ID)] + + +def test_a_report_of_neither_kind_is_not_found() -> None: + service = _service(FakeEconomyReportSimulation(), FakeHouseholdReportSimulation()) + + with pytest.raises(ReportNotFoundError, match="was not found in country 'us'"): + service.get_report(country_id="us", report_id=UNKNOWN_REPORT_ID) + + +def test_an_unavailable_economy_read_is_not_taken_for_a_missing_report() -> None: + economy_simulation = FakeEconomyReportSimulation() + economy_simulation.error = SimulationServiceUnavailableError("unreachable") + household_simulation = FakeHouseholdReportSimulation(household_report_record()) + + with pytest.raises(SimulationServiceUnavailableError): + _service(economy_simulation, household_simulation).get_report( + country_id="us", report_id=HOUSEHOLD_REPORT_ID + ) + + assert household_simulation.calls == [] + + +def test_a_listing_holds_both_kinds_in_the_order_asked() -> None: + economy_simulation = FakeEconomyReportSimulation( + report_record(run=report_run_record()) + ) + household_simulation = FakeHouseholdReportSimulation(household_report_record()) + + reports = _service(economy_simulation, household_simulation).list_reports( + country_id="us", + report_ids=[HOUSEHOLD_REPORT_ID, UNKNOWN_REPORT_ID, REPORT_ID], + ) + + assert [report.id for report in reports] == [HOUSEHOLD_REPORT_ID, REPORT_ID] + assert [argument for name, argument in household_simulation.calls] == [ + HOUSEHOLD_REPORT_ID, + UNKNOWN_REPORT_ID, + ] + + +def test_an_economy_report_is_run_again_without_asking_for_a_household_report() -> None: + economy_simulation = FakeEconomyReportSimulation( + report_record(run=report_run_record()) + ) + household_simulation = FakeHouseholdReportSimulation() + + run = _service(economy_simulation, household_simulation).run_report( + country_id="us", report_id=REPORT_ID, force=True + ) + + assert run.trigger == "manual" + assert household_simulation.calls == [] + + +def test_a_household_report_is_run_again_once_no_economy_report_has_its_id() -> None: + household_simulation = FakeHouseholdReportSimulation(household_report_record()) + + run = _service(FakeEconomyReportSimulation(), household_simulation).run_report( + country_id="us", report_id=HOUSEHOLD_REPORT_ID, force=True + ) + + assert run.report_id == HOUSEHOLD_REPORT_ID + assert [name for name, _argument in household_simulation.calls] == [ + "get_report", + "run_report", + ] + + +def test_a_household_report_of_a_household_only_country_is_run_again() -> None: + sessions = household_report_store() + set_country_support( + sessions, "us", household_enabled=True, society_wide_enabled=False + ) + household_simulation = FakeHouseholdReportSimulation(household_report_record()) + service = V2ReportService( + economy_reports=lambda: economy_report_service( + FakeEconomyReportSimulation(), sessions + ), + household_reports=lambda: household_report_service( + household_simulation, sessions=sessions + ), + ) + + run = service.run_report( + country_id="us", report_id=HOUSEHOLD_REPORT_ID, force=False + ) + + assert run.report_id == HOUSEHOLD_REPORT_ID + + +def test_a_rerun_of_neither_kind_keeps_the_household_only_refusal() -> None: + sessions = household_report_store() + set_country_support( + sessions, "us", household_enabled=True, society_wide_enabled=False + ) + service = V2ReportService( + economy_reports=lambda: economy_report_service( + FakeEconomyReportSimulation(), sessions + ), + household_reports=lambda: household_report_service( + FakeHouseholdReportSimulation(), sessions=sessions + ), + ) + + with pytest.raises(EconomyReportsUnsupportedError): + service.run_report(country_id="us", report_id=UNKNOWN_REPORT_ID, force=False) + + +def test_a_rerun_of_neither_kind_is_not_found() -> None: + service = _service(FakeEconomyReportSimulation(), FakeHouseholdReportSimulation()) + + with pytest.raises(ReportNotFoundError): + service.run_report(country_id="us", report_id=UNKNOWN_REPORT_ID, force=False) + + +def _unavailable_household_reports() -> FakeHouseholdReportSimulation: + household_simulation = FakeHouseholdReportSimulation(household_report_record()) + household_simulation.error = SimulationServiceUnavailableError("unreachable") + return household_simulation + + +def test_an_unavailable_household_read_after_an_economy_miss_is_raised() -> None: + service = _service(FakeEconomyReportSimulation(), _unavailable_household_reports()) + + with pytest.raises(SimulationServiceUnavailableError): + service.get_report(country_id="us", report_id=UNKNOWN_REPORT_ID) + + +def test_an_unavailable_household_listing_after_an_economy_miss_is_raised() -> None: + service = _service(FakeEconomyReportSimulation(), _unavailable_household_reports()) + + with pytest.raises(SimulationServiceUnavailableError): + service.list_reports(country_id="us", report_ids=[UNKNOWN_REPORT_ID]) + + +def test_an_unavailable_household_rerun_after_an_economy_miss_is_raised() -> None: + service = _service(FakeEconomyReportSimulation(), _unavailable_household_reports()) + + with pytest.raises(SimulationServiceUnavailableError): + service.run_report(country_id="us", report_id=UNKNOWN_REPORT_ID, force=False) + + +def _unavailable_economy_reports() -> FakeEconomyReportSimulation: + economy_simulation = FakeEconomyReportSimulation() + economy_simulation.error = SimulationServiceUnavailableError("unreachable") + return economy_simulation + + +def test_an_unavailable_economy_listing_asks_no_household_report() -> None: + household_simulation = FakeHouseholdReportSimulation(household_report_record()) + service = _service(_unavailable_economy_reports(), household_simulation) + + with pytest.raises(SimulationServiceUnavailableError): + service.list_reports(country_id="us", report_ids=[HOUSEHOLD_REPORT_ID]) + + assert household_simulation.calls == [] + + +def test_an_unavailable_economy_rerun_asks_no_household_report() -> None: + household_simulation = FakeHouseholdReportSimulation(household_report_record()) + service = _service(_unavailable_economy_reports(), household_simulation) + + with pytest.raises(SimulationServiceUnavailableError): + service.run_report(country_id="us", report_id=HOUSEHOLD_REPORT_ID, force=False) + + assert household_simulation.calls == [] + + +def test_an_unreadable_economy_report_in_a_listing_is_no_household_lookup() -> None: + """An economy report whose result cannot be read back is left out, as before. + + It is not missing, so its ID is not looked for among household reports: + an unavailable household route leaves the listing as the economy one was. + """ + economy_simulation = FakeEconomyReportSimulation( + report_record(run=report_run_record()) + ) + economy_simulation.unreadable_results.add(REPORT_ID) + household_simulation = FakeHouseholdReportSimulation() + household_simulation.error = SimulationServiceUnavailableError("unreachable") + + reports = _service(economy_simulation, household_simulation).list_reports( + country_id="us", report_ids=[REPORT_ID] + ) + + assert reports == () + assert household_simulation.calls == [] + + +def test_a_rerun_in_a_country_with_neither_kind_keeps_the_economy_refusal() -> None: + sessions = household_report_store() + set_country_support( + sessions, "us", household_enabled=False, society_wide_enabled=False + ) + household_simulation = FakeHouseholdReportSimulation() + service = V2ReportService( + economy_reports=lambda: economy_report_service( + FakeEconomyReportSimulation(), sessions + ), + household_reports=lambda: household_report_service( + household_simulation, sessions=sessions + ), + ) + + with pytest.raises( + EconomyReportsUnsupportedError, match="Economy reports are not available" + ): + service.run_report(country_id="us", report_id=UNKNOWN_REPORT_ID, force=False) + + assert household_simulation.calls == [] diff --git a/tests/unit/v2/test_saved_household_report_references.py b/tests/unit/v2/test_saved_household_report_references.py new file mode 100644 index 000000000..5515a2609 --- /dev/null +++ b/tests/unit/v2/test_saved_household_report_references.py @@ -0,0 +1,119 @@ +"""Saved reports that name v2 household reports, checked with the entrypoint. + +A saved report's v2 ID is checked with the Simulation Entrypoint before it is +saved; an ID that is not one of the country's economy reports may be one of +its household reports. +""" + +from __future__ import annotations + +import pytest + +from policyengine_api.services.v2.associations.validators import ( + AssociationReferenceConflictError, +) +from policyengine_api.services.v2.simulation_entrypoint.types import ( + SimulationServiceUnavailableError, +) +from policyengine_api.services.v2.simulation_refs.services import ( + SimulationReferenceService, +) +from policyengine_api.services.v2.simulation_refs.validators import ( + SimulationReferenceNotFoundError, +) +from tests.unit.v2.economy_report_support import ( + REPORT_ID, + FakeEconomyReportSimulation, + report_record, + report_run_record, +) +from tests.unit.v2.household_report_support import ( + HOUSEHOLD_REPORT_ID, + FakeHouseholdReportSimulation, + household_report_record, +) + + +def test_a_household_report_of_the_country_can_be_saved() -> None: + household_reports = FakeHouseholdReportSimulation(household_report_record()) + + SimulationReferenceService( + FakeEconomyReportSimulation(), household_reports=household_reports + ).require_report(country_id="us", report_id=HOUSEHOLD_REPORT_ID) + + assert household_reports.calls == [("get_report", HOUSEHOLD_REPORT_ID)] + + +def test_another_countrys_household_report_cannot_be_saved() -> None: + household_reports = FakeHouseholdReportSimulation( + household_report_record(country_id="uk") + ) + + with pytest.raises(SimulationReferenceNotFoundError): + SimulationReferenceService( + FakeEconomyReportSimulation(), household_reports=household_reports + ).require_report(country_id="us", report_id=HOUSEHOLD_REPORT_ID) + + +def test_a_report_of_neither_kind_cannot_be_saved() -> None: + with pytest.raises(SimulationReferenceNotFoundError): + SimulationReferenceService( + FakeEconomyReportSimulation(), + household_reports=FakeHouseholdReportSimulation(), + ).require_report(country_id="us", report_id=HOUSEHOLD_REPORT_ID) + + +def test_an_economy_report_is_checked_without_asking_for_a_household_report() -> None: + household_reports = FakeHouseholdReportSimulation() + + SimulationReferenceService( + FakeEconomyReportSimulation(report_record(run=report_run_record())), + household_reports=household_reports, + ).require_report(country_id="us", report_id=REPORT_ID) + + assert household_reports.calls == [] + + +def test_a_household_report_was_not_migrated_from_a_v1_report() -> None: + household_reports = FakeHouseholdReportSimulation(household_report_record()) + + with pytest.raises(AssociationReferenceConflictError, match="was not migrated"): + SimulationReferenceService( + FakeEconomyReportSimulation(), household_reports=household_reports + ).require_report( + country_id="us", report_id=HOUSEHOLD_REPORT_ID, legacy_report_id=615 + ) + + +def test_an_unavailable_household_check_is_not_taken_for_a_missing_report() -> None: + household_reports = FakeHouseholdReportSimulation(household_report_record()) + household_reports.error = SimulationServiceUnavailableError("unreachable") + + with pytest.raises(SimulationServiceUnavailableError): + SimulationReferenceService( + FakeEconomyReportSimulation(), household_reports=household_reports + ).require_report(country_id="us", report_id=HOUSEHOLD_REPORT_ID) + + +def test_the_default_checker_also_checks_household_reports( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from policyengine_api.fastapi_routes import dependencies + + household_reports = FakeHouseholdReportSimulation(household_report_record()) + monkeypatch.setattr( + dependencies, + "_default_economy_report_simulation", + FakeEconomyReportSimulation, + ) + monkeypatch.setattr( + dependencies, + "_default_household_report_simulation", + lambda: household_reports, + ) + + dependencies._default_simulation_reference_checker().require_report( + country_id="us", report_id=HOUSEHOLD_REPORT_ID + ) + + assert household_reports.calls == [("get_report", HOUSEHOLD_REPORT_ID)]