From a1371b7b5344c5a8ddd160610efddb90e1f45322 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:19:56 +0400 Subject: [PATCH 1/5] Add v3 metadata and saved reforms on the v2 routes, unused until the switch V3_API_ROUTES in libs/flagship/v3ApiRoutes.ts is the one place v3 chooses between the PolicyEngine API's v1 and v2 routes. It is a constant, 'v1', so production behaves as before; the switch pull request changes it to 'v2'. With 'v2': - the (workspace) route group loads model metadata through fetchV2Metadata: /v2/tax-benefit-models/by-country, then /v2/model-catalog and /v2/regions for that exact catalog version, converted to the v1 metadata shape (government parameters, v1 labels, region types and field names), plus the catalog's model and parameter UUIDs. The legacy calculator stays on GET /{country}/metadata; - saved reforms in the account (signed in or signed out) become /v2/policies policies, and /v2/user-policies rows point at them with policy_id; edits make a new policy and repoint the row. Rows that hold only a v1 policy (legacy_policy_id) still open, reading their parameters from GET /{country}/policy/{id} until the one-time migration links them. Reforms kept in this browser (no accounts) keep making v1 policies. Co-Authored-By: Claude Opus 5.5 --- app/src/api/publicApi/metadata.ts | 261 ++++++++++++ app/src/api/reformStore.ts | 307 +++++++++++++- app/src/hooks/useFlagshipMetadataStatus.ts | 13 +- app/src/hooks/useFlagshipStores.ts | 30 +- app/src/hooks/useMetadata.ts | 14 +- app/src/libs/flagship/v3ApiRoutes.ts | 31 ++ app/src/reducers/metadataReducer.ts | 48 ++- .../fixtures/api/publicApi/metadataV2Mocks.ts | 220 ++++++++++ .../tests/unit/api/publicApi/metadata.test.ts | 292 +++++++++++++ app/src/tests/unit/api/reformStoreV2.test.ts | 390 ++++++++++++++++++ .../useFlagshipMetadataStatusV2.test.tsx | 71 ++++ .../unit/hooks/useFlagshipStoresV2.test.tsx | 189 +++++++++ app/src/tests/unit/hooks/useMetadata.test.tsx | 37 +- .../unit/libs/flagship/v3ApiRoutes.test.ts | 19 + .../unit/reducers/metadataReducer.test.ts | 81 ++++ app/src/types/metadata.ts | 21 + app/src/types/v2User.ts | 7 +- .../src/app/[countryId]/providers.tsx | 7 +- 18 files changed, 2008 insertions(+), 30 deletions(-) create mode 100644 app/src/api/publicApi/metadata.ts create mode 100644 app/src/libs/flagship/v3ApiRoutes.ts create mode 100644 app/src/tests/fixtures/api/publicApi/metadataV2Mocks.ts create mode 100644 app/src/tests/unit/api/publicApi/metadata.test.ts create mode 100644 app/src/tests/unit/api/reformStoreV2.test.ts create mode 100644 app/src/tests/unit/hooks/useFlagshipMetadataStatusV2.test.tsx create mode 100644 app/src/tests/unit/hooks/useFlagshipStoresV2.test.tsx create mode 100644 app/src/tests/unit/libs/flagship/v3ApiRoutes.test.ts diff --git a/app/src/api/publicApi/metadata.ts b/app/src/api/publicApi/metadata.ts new file mode 100644 index 000000000..98187123e --- /dev/null +++ b/app/src/api/publicApi/metadata.ts @@ -0,0 +1,261 @@ +import { createPublicApiClient, requireResult, type PublicApiClient } from '@/api/publicApi/client'; +import type { components } from '@/api/publicApi/schema'; +import type { MetadataApiPayload, MetadataRegionEntry, V2CatalogReference } from '@/types/metadata'; +import type { ParameterMetadataCollection } from '@/types/metadata/parameterMetadata'; +import type { ValuesList } from '@/types/subIngredients/valueInterval'; + +/** + * The v3 workspace's model metadata from the PolicyEngine API's v2 routes, + * in the shape `GET /{country}/metadata` returns, so everything that reads + * the metadata store works unchanged: + * + * 1. `GET /v2/tax-benefit-models/by-country/{country}` selects the catalog + * version (and gives entities, basic inputs, years and current law); + * 2. `GET /v2/model-catalog` and `GET /v2/regions` read that exact version. + * + * v1 left out things v3 never reads (variable modules, modelled policies, + * datasets) and the US SPM settings, which v2 does not publish; those come + * back empty here. + */ + +type Schemas = components['schemas']; + +/** Region types v1 listed per country; v2 also has places, which v3 never offered. */ +const V1_REGION_TYPES: Record = { + us: ['national', 'state', 'congressional_district'], + uk: ['national', 'country', 'constituency', 'local_authority'], +}; + +const REGION_PAGE_SIZE = 500; +/** A generous bound so a misbehaving server cannot loop forever. */ +const MAXIMUM_REGION_PAGES = 20; + +/** v1 metadata covered only the government parameter tree. */ +function isGovernmentPath(path: string): boolean { + return path.startsWith('gov'); +} + +/** + * v1's label for a parameter or node without a label of its own: "bracket N" + * for a bracket, otherwise the last path segment with spaces for underscores. + */ +function v1FallbackLabel(path: string): string { + const bracket = /\[(\d+)\]$/.exec(path); + if (bracket) { + return `bracket ${Number(bracket[1]) + 1}`; + } + return (path.split('.').pop() ?? path).replaceAll('_', ' '); +} + +function parameterCollection( + catalog: Schemas['MetadataCatalogResult'], + hasPresentationMetadata: boolean +): ParameterMetadataCollection { + const parameters: ParameterMetadataCollection = {}; + for (const node of catalog.parameter_nodes) { + if (!isGovernmentPath(node.name)) { + continue; + } + parameters[node.name] = { + type: 'parameterNode', + parameter: node.name, + label: node.label ?? v1FallbackLabel(node.name), + description: node.description, + }; + } + for (const parameter of catalog.parameters) { + if (!isGovernmentPath(parameter.name)) { + continue; + } + // Before catalog format 2 there is no metadata label; PolicyEngine.py's + // generated label is the closest stand-in. + const ownLabel = hasPresentationMetadata ? parameter.metadata_label : parameter.label; + parameters[parameter.name] = { + type: 'parameter', + parameter: parameter.name, + label: ownLabel ?? v1FallbackLabel(parameter.name), + description: parameter.description, + unit: parameter.unit, + period: parameter.period ?? null, + values: parameter.values as ValuesList, + economy: parameter.economy ?? true, + household: parameter.household ?? true, + }; + } + return parameters; +} + +/** v1 kept only numeric and boolean defaults, and an enum's value name. */ +function v1DefaultValue(variable: Schemas['MetadataVariable']): unknown { + const value = variable.default_value; + if (variable.possible_values) { + return value; + } + return typeof value === 'number' || typeof value === 'boolean' ? value : null; +} + +function variableCollection(catalog: Schemas['MetadataCatalogResult']): Record { + const variables: Record = {}; + for (const variable of catalog.variables) { + variables[variable.name] = { + name: variable.name, + label: variable.label, + entity: variable.entity, + documentation: variable.description, + valueType: variable.value_type ?? (variable.possible_values ? 'Enum' : variable.data_type), + definitionPeriod: variable.definition_period ?? null, + unit: variable.unit ?? null, + moduleName: variable.module_name ?? null, + isInputVariable: variable.is_input_variable ?? false, + hidden_input: variable.hidden_input ?? false, + defaultValue: v1DefaultValue(variable), + adds: variable.adds, + subtracts: variable.subtracts, + ...(variable.possible_values + ? { + possibleValues: variable.possible_values.map((value) => ({ + value, + label: variable.possible_value_labels?.[value] ?? value, + })), + } + : {}), + }; + } + return variables; +} + +function entityCollection( + entities: Schemas['MetadataEntity'][] | null | undefined +): Record { + return Object.fromEntries( + (entities ?? []).map((entity) => [ + entity.key, + { + key: entity.key, + plural: entity.plural, + label: entity.label, + doc: entity.description, + is_person: entity.is_person, + roles: Object.fromEntries( + entity.roles.map((role) => [ + role.key, + { plural: role.plural, label: role.label, doc: role.description }, + ]) + ), + }, + ]) + ); +} + +function regionEntries(countryId: string, regions: Schemas['MetadataRegion'][]) { + const offered = V1_REGION_TYPES[countryId] ?? ['national']; + return regions + .filter((region) => offered.includes(region.region_type)) + .sort( + (first, second) => offered.indexOf(first.region_type) - offered.indexOf(second.region_type) + ) + .map( + (region) => + ({ + name: region.code, + label: region.label, + type: region.region_type, + // v1 gave the state only for congressional districts. + ...(region.region_type === 'congressional_district' && region.state_code + ? { state_abbreviation: region.state_code, state_name: region.state_name ?? '' } + : {}), + }) as MetadataRegionEntry + ); +} + +function catalogReference( + selection: Schemas['MetadataModelSelectionResult'], + catalog: Schemas['MetadataCatalogResult'] +): V2CatalogReference { + return { + policyengineVersion: catalog.policyengine_version, + taxBenefitModelId: selection.model.id, + parameterIds: Object.fromEntries( + catalog.parameters + .filter((parameter) => isGovernmentPath(parameter.name)) + .map((parameter) => [parameter.name, parameter.id]) + ), + }; +} + +async function readRegions( + client: PublicApiClient, + countryId: string, + policyengineVersion: string +): Promise { + const regions: Schemas['MetadataRegion'][] = []; + for (let page = 0; page < MAXIMUM_REGION_PAGES; page += 1) { + const result = await requireResult( + client.GET('/v2/regions', { + params: { + query: { + country_id: countryId, + policyengine_version: policyengineVersion, + offset: page * REGION_PAGE_SIZE, + limit: REGION_PAGE_SIZE, + }, + }, + }) + ); + regions.push(...result.items); + if (!result.has_more || result.items.length === 0) { + break; + } + } + return regions; +} + +/** A country's model metadata from the v2 routes, as a v1 metadata envelope. */ +export async function fetchV2Metadata( + countryId: string, + client: PublicApiClient = createPublicApiClient() +): Promise { + const selection = await requireResult( + client.GET('/v2/tax-benefit-models/by-country/{country_id}', { + params: { path: { country_id: countryId } }, + }) + ); + const policyengineVersion = selection.policyengine_version; + const [catalog, regions] = await Promise.all([ + requireResult( + client.GET('/v2/model-catalog', { + params: { + query: { country_id: countryId, policyengine_version: policyengineVersion }, + }, + }) + ), + readRegions(client, countryId, policyengineVersion), + ]); + const modelVersion = selection.model_version; + const hasPresentationMetadata = + modelVersion.catalog_format !== null && modelVersion.catalog_format !== undefined; + + return { + status: 'ok', + message: null, + result: { + variables: variableCollection(catalog), + parameters: parameterCollection(catalog, hasPresentationMetadata), + entities: entityCollection(modelVersion.entities), + variableModules: {}, + economy_options: { + region: regionEntries(countryId, regions), + time_period: modelVersion.metadata_time_periods.map((year) => ({ + name: year, + label: String(year), + })), + datasets: [], + }, + current_law_id: modelVersion.current_law_id, + basicInputs: modelVersion.basic_inputs ?? [], + modelled_policies: { core: {}, filtered: {} }, + version: policyengineVersion, + v2Catalog: catalogReference(selection, catalog), + }, + }; +} diff --git a/app/src/api/reformStore.ts b/app/src/api/reformStore.ts index 411c7f572..471f6ad32 100644 --- a/app/src/api/reformStore.ts +++ b/app/src/api/reformStore.ts @@ -1,5 +1,7 @@ import { convertDateRangeMapToValueIntervals } from '@/adapters/conversionHelpers'; import { fetchPolicyById } from '@/api/policy'; +import { createPublicApiClient, requireResult, type PublicApiClient } from '@/api/publicApi/client'; +import type { components } from '@/api/publicApi/schema'; import { AccountAssociationRoutes, BrowserOnlyIdError, @@ -7,9 +9,12 @@ import { isLegacyId, type ApiStoreContext, } from '@/api/v2Associations'; +import { FOREVER } from '@/constants'; import { createReformPolicy } from '@/libs/flagship/runReport'; import { clampStoreText } from '@/libs/flagship/storeText'; +import { V3_API_ROUTES, type V3ApiRoutes } from '@/libs/flagship/v3ApiRoutes'; import { Reform, ReformBaseline, ReformProvenance } from '@/types/ingredients/Reform'; +import type { V2CatalogReference } from '@/types/metadata'; import type { ParameterMetadataCollection } from '@/types/metadata/parameterMetadata'; import type { PolicyMetadataParams } from '@/types/metadata/policyMetadata'; import { Parameter } from '@/types/subIngredients/parameter'; @@ -20,6 +25,7 @@ import type { UserPolicyItem, UserPolicyPatchBody, } from '@/types/v2User'; +import { coercePolicyParameterValue, resolvePolicyParameterValueSpec } from '@/utils/valueCoercion'; /** * Store for the flagship app's reform objects. @@ -403,10 +409,305 @@ export class ApiReformStore implements ReformStore { } } -/** The reform store for a session: the account's when given one, else localStorage. */ +/* Saved reforms on the v2 routes (V3_API_ROUTES 'v2'). */ + +type V2PolicyItem = components['schemas']['PolicyItem']; +type V2PolicyCreateBody = components['schemas']['PolicyCreateRequest']; + +/** A v2 policy's JSON value: infinities as the API spells them (JSON has none). */ +function v2PolicyValue(value: unknown): unknown { + if (value === Infinity) { + return 'Infinity'; + } + if (value === -Infinity) { + return '-Infinity'; + } + return value; +} + +/** A date as the UTC midnight the v2 API stores (v1 periods are whole days). */ +function utcMidnight(date: string): string { + return `${date}T00:00:00Z`; +} + +/** + * The `POST /v2/policies` body for a reform's parameters: each dated value + * names its parameter's catalog UUID, and is typed as a v1 run types it when + * the parameter's metadata is loaded. Dates keep v1's whole-day periods + * (including its 2100-12-31 "forever"), as the dual-write copies of v1 + * policies do, so identical changes deduplicate to one v2 policy. + */ +export function v2ReformPolicyBody( + countryId: string, + parameters: Parameter[], + catalog: V2CatalogReference, + metadata?: ParameterMetadataCollection +): V2PolicyCreateBody { + return { + country_id: countryId as V2PolicyCreateBody['country_id'], + tax_benefit_model_id: catalog.taxBenefitModelId, + parameter_values: parameters.flatMap((parameter) => { + const parameterId = catalog.parameterIds[parameter.name]; + if (!parameterId) { + throw new Error( + `${parameter.name} is not a parameter of the PolicyEngine model ${catalog.policyengineVersion}` + ); + } + const valueSpec = + metadata?.[parameter.name]?.type === 'parameter' + ? resolvePolicyParameterValueSpec(parameter.name, metadata) + : null; + return parameter.values.map((interval) => ({ + parameter_id: parameterId, + value: v2PolicyValue( + valueSpec + ? coercePolicyParameterValue(interval.value, valueSpec, parameter.name) + : interval.value + ) as V2PolicyCreateBody['parameter_values'][number]['value'], + start_date: utcMidnight(interval.startDate), + end_date: utcMidnight(interval.endDate), + })); + }), + }; +} + +/** Creates (or, as the API deduplicates, finds) a reform's v2 policy; returns its ID. */ +export type CreateV2ReformPolicy = ( + countryId: string, + parameters: Parameter[], + catalog: V2CatalogReference, + metadata?: ParameterMetadataCollection +) => Promise; + +export async function createV2ReformPolicy( + countryId: string, + parameters: Parameter[], + catalog: V2CatalogReference, + metadata?: ParameterMetadataCollection, + client: PublicApiClient = createPublicApiClient() +): Promise { + const body = v2ReformPolicyBody(countryId, parameters, catalog, metadata); + const result = await requireResult( + client.POST('/v2/policies', { + params: { + query: { + country_id: body.country_id, + policyengine_version: catalog.policyengineVersion, + }, + }, + body, + }) + ); + return result.item.id; +} + +/** A v2 policy. Policies never change, so callers may cache them. */ +export async function fetchV2PolicyItem( + countryId: string, + policyId: string, + client: PublicApiClient = createPublicApiClient() +): Promise { + const result = await requireResult( + client.GET('/v2/policies/{policy_id}', { + params: { + path: { policy_id: policyId }, + query: { country_id: countryId as V2PolicyCreateBody['country_id'] }, + }, + }) + ); + return result.item; +} + +/** A v2 policy's values as reform parameters, each oldest first; an open end is v1's forever. */ +export function parametersFromV2Policy( + policy: Pick +): Parameter[] { + const byName = new Map(); + for (const parameterValue of policy.parameter_values) { + const intervals = byName.get(parameterValue.parameter_name) ?? []; + intervals.push({ + startDate: parameterValue.start_date.slice(0, 10), + endDate: parameterValue.end_date ? parameterValue.end_date.slice(0, 10) : FOREVER, + value: parameterValue.value as ValueInterval['value'], + }); + byName.set(parameterValue.parameter_name, intervals); + } + return [...byName].map(([name, values]) => ({ + name, + values: values.sort((first, second) => first.startDate.localeCompare(second.startDate)), + })); +} + +export interface ApiV2ReformStoreOptions extends ApiReformStoreOptions { + /** The loaded v2 catalog: its model and parameter UUIDs (see V2CatalogReference). */ + getV2Catalog?: () => V2CatalogReference | null | undefined; + createV2Policy?: CreateV2ReformPolicy; + /** Reads a v2 policy. */ + fetchV2Policy?: (countryId: string, policyId: string) => Promise; +} + +/** The model catalog must be loaded from the v2 routes before a v2 policy can be made. */ +export class V2CatalogUnavailableError extends Error { + constructor() { + super('The PolicyEngine model has not loaded yet, so this reform cannot be saved.'); + this.name = 'V2CatalogUnavailableError'; + } +} + +/** Whether an account row is a v3 reform: reforms always carry provenance. */ +function isV3Reform(item: UserPolicyItem): boolean { + return item.provenance !== null && (item.policy_id !== null || isLegacyId(item.legacy_policy_id)); +} + +/** + * Reforms in the account (`/v2/user-policies`) on v2 policies. A save makes + * the parameters a `/v2/policies` policy and the row points at it with + * `policy_id`; parameter edits make a new policy and repoint the row (the + * API then clears any `legacy_policy_id`); a rename only patches the name. + * Rows saved before the switch that hold only a v1 policy + * (`legacy_policy_id`) still open: their parameters are read from + * `GET /{country}/policy/{id}` until the one-time migration links them to + * v2 policies. + */ +export class ApiV2ReformStore implements ReformStore { + private readonly routes: AccountAssociationRoutes; + private readonly getParameterMetadata: NonNullable; + private readonly getV2Catalog: NonNullable; + private readonly createV2Policy: CreateV2ReformPolicy; + private readonly fetchV2Policy: NonNullable; + private readonly fetchPolicyJson: NonNullable; + + constructor( + private readonly context: ApiStoreContext, + options: ApiV2ReformStoreOptions = {} + ) { + this.routes = new AccountAssociationRoutes(context, 'user-policies', 'Reform'); + this.getParameterMetadata = options.getParameterMetadata ?? (() => null); + this.getV2Catalog = options.getV2Catalog ?? (() => null); + this.createV2Policy = options.createV2Policy ?? createV2ReformPolicy; + this.fetchV2Policy = options.fetchV2Policy ?? fetchV2PolicyItem; + this.fetchPolicyJson = options.fetchPolicyJson ?? fetchV1PolicyJson; + } + + /** The v2 policy for a set of parameters, created for the loaded catalog. */ + private materialize(countryId: string, parameters: Parameter[]): Promise { + const catalog = this.getV2Catalog(); + if (!catalog) { + throw new V2CatalogUnavailableError(); + } + return this.createV2Policy( + countryId, + parameters, + catalog, + this.getParameterMetadata() ?? undefined + ); + } + + async create(reform: NewReform): Promise { + const countryId = this.routes.writeCountry(reform.countryId); + const policyId = isAccountId(reform.policyId) + ? reform.policyId + : await this.materialize(countryId, reform.parameters); + const item = await this.routes.create({ + country_id: countryId, + policy_id: policyId, + ...(reform.label ? { name: clampStoreText(reform.label) } : {}), + baseline: reform.baseline, + provenance: provenanceToWire(reform.provenance), + }); + return this.reformFromItem(item, reform.parameters); + } + + async findMine(countryId?: string): Promise { + if (countryId && countryId !== this.context.countryId) { + return []; + } + const items = (await this.routes.list()).filter(isV3Reform); + const loaded = await Promise.allSettled(items.map((item) => this.toReform(item))); + return byRecentUpdate( + loaded.map((outcome, index) => + outcome.status === 'fulfilled' + ? outcome.value + : { ...this.reformFromItem(items[index], []), parametersUnavailable: true } + ) + ); + } + + async findById(reformId: string): Promise { + const item = await this.routes.get(reformId); + return item && isV3Reform(item) ? this.toReform(item) : null; + } + + async update(reformId: string, updates: ReformUpdates): Promise { + // Refuse a browser ID before creating a policy for it. + if (!isAccountId(reformId)) { + throw new BrowserOnlyIdError(); + } + const body: UserPolicyPatchBody = {}; + if (updates.label !== undefined) { + body.name = clampStoreText(updates.label) || null; + } + if (updates.baseline !== undefined) { + body.baseline = updates.baseline; + } + if (updates.provenance !== undefined) { + body.provenance = provenanceToWire(updates.provenance); + } + if (updates.parameters !== undefined) { + body.policy_id = await this.materialize(this.context.countryId, updates.parameters); + } else if (isAccountId(updates.policyId)) { + body.policy_id = updates.policyId; + } + const item = await this.routes.patch(reformId, body); + return updates.parameters !== undefined + ? this.reformFromItem(item, updates.parameters) + : this.toReform(item); + } + + delete(reformId: string): Promise { + return this.routes.delete(reformId); + } + + private async toReform(item: UserPolicyItem): Promise { + const parameters = item.policy_id + ? parametersFromV2Policy(await this.fetchV2Policy(item.country_id, item.policy_id)) + : parametersFromPolicyJson( + await this.fetchPolicyJson(item.country_id, String(item.legacy_policy_id)), + this.getParameterMetadata() + ); + return this.reformFromItem(item, parameters); + } + + private reformFromItem(item: UserPolicyItem, parameters: Parameter[]): Reform { + return { + id: item.id, + countryId: item.country_id, + label: item.name ?? undefined, + policyId: item.policy_id ?? String(item.legacy_policy_id), + parameters, + baseline: (item.baseline ?? 'current-law') as ReformBaseline, + provenance: provenanceFromWire(item.provenance), + createdAt: item.created_at, + updatedAt: item.updated_at, + }; + } +} + +/** + * The reform store for a session: the account's when given one, else + * localStorage. With an account, `routes` (V3_API_ROUTES unless a caller + * says otherwise) picks v1 or v2 policies; reforms kept in this browser + * make v1 policies either way. + */ export function getReformStore( account?: ApiStoreContext | null, - options?: ApiReformStoreOptions + options?: ApiV2ReformStoreOptions, + routes: V3ApiRoutes = V3_API_ROUTES ): ReformStore { - return account ? new ApiReformStore(account, options) : new LocalStorageReformStore(options); + if (!account) { + return new LocalStorageReformStore(options); + } + return routes === 'v2' + ? new ApiV2ReformStore(account, options) + : new ApiReformStore(account, options); } diff --git a/app/src/hooks/useFlagshipMetadataStatus.ts b/app/src/hooks/useFlagshipMetadataStatus.ts index 4c18ee752..bda72704c 100644 --- a/app/src/hooks/useFlagshipMetadataStatus.ts +++ b/app/src/hooks/useFlagshipMetadataStatus.ts @@ -1,7 +1,8 @@ import { useCallback } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; -import { fetchMetadataThunk } from '@/reducers/metadataReducer'; +import { V3_API_ROUTES } from '@/libs/flagship/v3ApiRoutes'; +import { fetchMetadataThunk, metadataRequest } from '@/reducers/metadataReducer'; import type { AppDispatch, RootState } from '@/store'; export type FlagshipMetadataStatus = 'loading' | 'error' | 'ready'; @@ -22,12 +23,18 @@ export function useFlagshipMetadataStatus(): { const dispatch = useDispatch(); const loading = useSelector((state: RootState) => state.metadata.loading); const error = useSelector((state: RootState) => state.metadata.error); + // Ready only with metadata from the routes v3 uses (see v3ApiRoutes). const ready = useSelector( (state: RootState) => - state.metadata.version !== null && state.metadata.currentCountry === countryId + state.metadata.version !== null && + state.metadata.currentCountry === countryId && + (state.metadata.routes ?? 'v1') === V3_API_ROUTES ); - const retry = useCallback(() => dispatch(fetchMetadataThunk(countryId)), [dispatch, countryId]); + const retry = useCallback( + () => dispatch(fetchMetadataThunk(metadataRequest(countryId, V3_API_ROUTES))), + [dispatch, countryId] + ); const status: FlagshipMetadataStatus = !loading && error ? 'error' : ready && !loading ? 'ready' : 'loading'; diff --git a/app/src/hooks/useFlagshipStores.ts b/app/src/hooks/useFlagshipStores.ts index fecc89b22..785e79be6 100644 --- a/app/src/hooks/useFlagshipStores.ts +++ b/app/src/hooks/useFlagshipStores.ts @@ -8,7 +8,12 @@ import { type FlagshipReportRecord, type FlagshipReportStore, } from '@/api/flagshipReportStore'; -import { fetchV1PolicyJson, getReformStore, type ReformStore } from '@/api/reformStore'; +import { + fetchV1PolicyJson, + fetchV2PolicyItem, + getReformStore, + type ReformStore, +} from '@/api/reformStore'; import { fetchSharedReport } from '@/api/sharedReport'; import { isAccountId, type ApiStoreContext } from '@/api/v2Associations'; import { useCountryContext } from '@/contexts/CountryContext'; @@ -65,22 +70,34 @@ export function useV3SavedWork(): V3SavedWork { const v1PolicyJsonKey = (countryId: string, policyId: string) => ['v1-policy-json', countryId, policyId] as const; +/** A v2 policy (`GET /v2/policies/{id}`), per country. */ +const v2PolicyKey = (countryId: string, policyId: string) => + ['v2-policy', countryId, policyId] as const; + /** How long a fetched v1 policy stays cached without a reader. */ const V1_POLICY_JSON_GC_TIME = 30 * 60 * 1000; +/** + * The reform store for this session. Which policies it makes (v1 or v2) + * follows V3_API_ROUTES (see libs/flagship/v3ApiRoutes). + */ export function useReformStore(): ReformStore { const { account } = useV3SavedWork(); const parameters = useSelector((state: RootState) => state.metadata?.parameters); - // Read through a ref so metadata arriving later does not rebuild the store. + const v2Catalog = useSelector((state: RootState) => state.metadata?.v2Catalog); + // Read through refs so metadata arriving later does not rebuild the store. const parametersRef = useRef(parameters); parametersRef.current = parameters; + const v2CatalogRef = useRef(v2Catalog); + v2CatalogRef.current = v2Catalog; const queryClient = useQueryClient(); return useMemo( () => getReformStore(account, { getParameterMetadata: () => parametersRef.current, - // v1 policies never change, so each is fetched once per session + getV2Catalog: () => v2CatalogRef.current, + // Policies never change, so each is fetched once per session // rather than on every list of the account's reforms. fetchPolicyJson: (countryId, policyId) => queryClient.fetchQuery({ @@ -89,6 +106,13 @@ export function useReformStore(): ReformStore { staleTime: Infinity, gcTime: V1_POLICY_JSON_GC_TIME, }), + fetchV2Policy: (countryId, policyId) => + queryClient.fetchQuery({ + queryKey: v2PolicyKey(countryId, policyId), + queryFn: () => fetchV2PolicyItem(countryId, policyId), + staleTime: Infinity, + gcTime: V1_POLICY_JSON_GC_TIME, + }), }), [account, queryClient] ); diff --git a/app/src/hooks/useMetadata.ts b/app/src/hooks/useMetadata.ts index e6724d212..d84101d25 100644 --- a/app/src/hooks/useMetadata.ts +++ b/app/src/hooks/useMetadata.ts @@ -1,7 +1,8 @@ import { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { fetchMetadataThunk } from '@/reducers/metadataReducer'; +import { fetchMetadataThunk, metadataRequest } from '@/reducers/metadataReducer'; import { AppDispatch, RootState } from '@/store'; +import type { MetadataRoutes } from '@/types/metadata'; /** * Hook that ensures metadata is fetched for the current country. @@ -13,14 +14,15 @@ import { AppDispatch, RootState } from '@/store'; * * Components should use useSelector to read metadata from Redux state */ -export function useFetchMetadata(countryId: string): void { +export function useFetchMetadata(countryId: string, routes: MetadataRoutes = 'v1'): void { const dispatch = useDispatch(); const metadata = useSelector((state: RootState) => state.metadata); + const loadedRoutes = metadata.routes ?? 'v1'; useEffect(() => { - // Only fetch if we don't already have metadata for this country - if (!metadata.version || countryId !== metadata.currentCountry) { - dispatch(fetchMetadataThunk(countryId)); + // Only fetch if we don't already have metadata for this country from these routes + if (!metadata.version || countryId !== metadata.currentCountry || loadedRoutes !== routes) { + dispatch(fetchMetadataThunk(metadataRequest(countryId, routes))); } - }, [countryId, metadata.currentCountry, metadata.version, dispatch]); + }, [countryId, routes, loadedRoutes, metadata.currentCountry, metadata.version, dispatch]); } diff --git a/app/src/libs/flagship/v3ApiRoutes.ts b/app/src/libs/flagship/v3ApiRoutes.ts new file mode 100644 index 000000000..539f70907 --- /dev/null +++ b/app/src/libs/flagship/v3ApiRoutes.ts @@ -0,0 +1,31 @@ +/** + * Which PolicyEngine API routes the v3 workspace uses. + * + * - 'v1': `GET /{country}/metadata` for the model, and saved reforms as + * `/v2/user-policies` rows pointing at v1 policies (`POST /{country}/policy`, + * `legacy_policy_id`). + * - 'v2': the model from `/v2/tax-benefit-models/by-country`, + * `/v2/model-catalog` and `/v2/regions`, and saved reforms as + * `/v2/user-policies` rows pointing at `/v2/policies` (`policy_id`). + * + * This constant is the only place v3 chooses. It is not a runtime flag or an + * environment variable, so every build behaves the same. It stays 'v1' until + * the switch pull request (plan item 4A-1), which changes this line to 'v2' + * (and the test that pins it). The legacy calculator, the `(calculator)` route + * group, stays on v1 regardless. + */ +export type V3ApiRoutes = 'v1' | 'v2'; + +export const V3_API_ROUTES: V3ApiRoutes = 'v1'; + +/** The Next.js route group holding the v3 workspace pages. */ +export const V3_WORKSPACE_ROUTE_GROUP = '(workspace)'; + +/** + * Where model metadata loads from for a page in this route group (the + * segment below `[countryId]`): v3 follows V3_API_ROUTES; everything else + * stays on v1. + */ +export function metadataRoutesForRouteGroup(routeGroup: string | null): V3ApiRoutes { + return routeGroup === V3_WORKSPACE_ROUTE_GROUP ? V3_API_ROUTES : 'v1'; +} diff --git a/app/src/reducers/metadataReducer.ts b/app/src/reducers/metadataReducer.ts index e1daefd24..f8e9aaa04 100644 --- a/app/src/reducers/metadataReducer.ts +++ b/app/src/reducers/metadataReducer.ts @@ -1,8 +1,27 @@ import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; -// Import the API function +// Import the API functions import { fetchMetadata as fetchMetadataApi } from '@/api/metadata'; +import { fetchV2Metadata } from '@/api/publicApi/metadata'; import { buildParameterTree } from '@/libs/buildParameterTree'; -import { MetadataState } from '@/types/metadata'; +import { MetadataRoutes, MetadataState } from '@/types/metadata'; + +/** + * What to load: a country alone reads `GET /{country}/metadata` (v1), as + * every caller always has; `{countryId, routes: 'v2'}` reads the v2 routes. + */ +export type MetadataRequest = string | { countryId: string; routes: MetadataRoutes }; + +export function metadataRequest(countryId: string, routes: MetadataRoutes): MetadataRequest { + return routes === 'v1' ? countryId : { countryId, routes }; +} + +function requestCountry(request: MetadataRequest): string { + return typeof request === 'string' ? request : request.countryId; +} + +function requestRoutes(request: MetadataRequest): MetadataRoutes { + return typeof request === 'string' ? 'v1' : request.routes; +} const initialState: MetadataState = { loading: false, @@ -25,13 +44,17 @@ const initialState: MetadataState = { // Async thunk for fetching metadata export const fetchMetadataThunk = createAsyncThunk< { data: Awaited>; country: string }, - string, + MetadataRequest, { state: { metadata: MetadataState } } >( 'metadata/fetch', - async (country: string, { rejectWithValue }) => { + async (request: MetadataRequest, { rejectWithValue }) => { + const country = requestCountry(request); try { - const data = await fetchMetadataApi(country); + const data = + requestRoutes(request) === 'v2' + ? await fetchV2Metadata(country) + : await fetchMetadataApi(country); if (data.status !== 'ok' || !data.result?.version) { return rejectWithValue( data.message || 'Model information did not include a resolved version' @@ -44,9 +67,13 @@ export const fetchMetadataThunk = createAsyncThunk< }, { // Prevent duplicate fetches (e.g. from React StrictMode double-invoking effects) - condition: (country, { getState }) => { + condition: (request, { getState }) => { const { metadata } = getState(); - if (metadata.loading && metadata.currentCountry === country) { + if ( + metadata.loading && + metadata.currentCountry === requestCountry(request) && + (metadata.routes ?? 'v1') === requestRoutes(request) + ) { return false; } }, @@ -74,9 +101,10 @@ const metadataSlice = createSlice({ .addCase(fetchMetadataThunk.pending, (_state, action) => { return { ...initialState, - currentCountry: action.meta.arg, + currentCountry: requestCountry(action.meta.arg), currentRequestId: action.meta.requestId, loading: true, + routes: requestRoutes(action.meta.arg), }; }) .addCase(fetchMetadataThunk.fulfilled, (state, action) => { @@ -106,6 +134,8 @@ const metadataSlice = createSlice({ state.modelledPolicies = body.modelled_policies; state.version = body.version; state.spm = body.spm; + state.routes = requestRoutes(action.meta.arg); + state.v2Catalog = body.v2Catalog ?? null; // Build parameter tree from parameters (following V1 approach) try { @@ -118,7 +148,7 @@ const metadataSlice = createSlice({ if ( !state.currentRequestId || state.currentRequestId !== action.meta.requestId || - state.currentCountry !== action.meta.arg + state.currentCountry !== requestCountry(action.meta.arg) ) { return; } diff --git a/app/src/tests/fixtures/api/publicApi/metadataV2Mocks.ts b/app/src/tests/fixtures/api/publicApi/metadataV2Mocks.ts new file mode 100644 index 000000000..27de937c8 --- /dev/null +++ b/app/src/tests/fixtures/api/publicApi/metadataV2Mocks.ts @@ -0,0 +1,220 @@ +import type { components } from '@/api/publicApi/schema'; + +type Schemas = components['schemas']; + +export const V2_POLICYENGINE_VERSION = '6.7.5'; +export const V2_MODEL_ID = '0b6c4f6e-7c55-5d47-9a51-4e9f5fbbf001'; +export const V2_MODEL_VERSION_ID = '0b6c4f6e-7c55-5d47-9a51-4e9f5fbbf002'; + +export const V2_PARAMETER_IDS = { + CTC_AMOUNT: '6a1f0c1e-1111-5aaa-8bbb-000000000001', + CTC_PHASE_OUT: '6a1f0c1e-1111-5aaa-8bbb-000000000002', + TAXSIM_SWITCH: '6a1f0c1e-1111-5aaa-8bbb-000000000003', + CALIBRATION_TARGET: '6a1f0c1e-1111-5aaa-8bbb-000000000004', + BRACKET_RATE: '6a1f0c1e-1111-5aaa-8bbb-000000000005', +} as const; + +export const V2_ENTITIES: Schemas['MetadataEntity'][] = [ + { + key: 'person', + plural: 'people', + label: 'Person', + description: 'An individual.', + is_person: true, + roles: [], + }, + { + key: 'tax_unit', + plural: 'tax_units', + label: 'Tax unit', + description: 'A tax unit.', + is_person: false, + roles: [{ key: 'member', plural: 'members', label: 'Member', description: null }], + }, +]; + +export function modelSelection( + overrides: Partial = {} +): Schemas['MetadataModelSelectionResult'] { + return { + policyengine_version: V2_POLICYENGINE_VERSION, + model: { id: V2_MODEL_ID, name: 'policyengine-us', description: 'US model' }, + model_version: { + id: V2_MODEL_VERSION_ID, + model_id: V2_MODEL_ID, + version: V2_POLICYENGINE_VERSION, + description: 'US model', + current_law_id: 2, + metadata_time_periods: [2027, 2026, 2025], + catalog_format: 2, + entities: V2_ENTITIES, + basic_inputs: ['state_name', 'employment_income', 'age'], + ...overrides, + }, + }; +} + +function catalogParameter( + overrides: Partial & + Pick +): Schemas['MetadataCatalogParameter'] { + return { + label: null, + description: null, + data_type: 'float', + unit: null, + metadata_label: null, + period: null, + economy: true, + household: true, + values: {}, + ...overrides, + }; +} + +export function modelCatalog( + overrides: Partial = {} +): Schemas['MetadataCatalogResult'] { + return { + policyengine_version: V2_POLICYENGINE_VERSION, + parameter_nodes: [ + { name: 'gov', label: 'Government', description: null }, + { name: 'gov.irs.credits.ctc', label: 'Child Tax Credit', description: null }, + { name: 'gov.irs.income.bracket', label: null, description: null }, + { name: 'gov.irs.income.bracket.rates[0]', label: null, description: null }, + ], + parameters: [ + catalogParameter({ + id: V2_PARAMETER_IDS.CALIBRATION_TARGET, + name: 'calibration.gov.cbo.income_tax', + label: 'CBO income tax', + values: { '2024-01-01': 2_000_000_000_000 }, + }), + catalogParameter({ + id: V2_PARAMETER_IDS.CTC_AMOUNT, + name: 'gov.irs.credits.ctc.amount.base[0].amount', + label: 'Child Tax Credit base amount (bracket 1 amount)', + metadata_label: 'CTC base amount', + description: 'Maximum credit per child', + unit: 'currency-USD', + period: 'year', + values: { '2024-01-01': 2000, '2026-01-01': 2200 }, + }), + catalogParameter({ + id: V2_PARAMETER_IDS.CTC_PHASE_OUT, + name: 'gov.irs.credits.ctc.phase_out.threshold', + label: 'Child Tax Credit phase-out threshold', + unit: 'currency-USD', + economy: false, + household: true, + values: { '2018-01-01': 'Infinity' }, + }), + catalogParameter({ + id: V2_PARAMETER_IDS.BRACKET_RATE, + name: 'gov.irs.income.bracket.rates[0].rate', + label: 'Income tax rate (bracket 1 rate)', + unit: '/1', + values: { '2018-01-01': 0.1 }, + }), + ], + variables: [ + { + id: '7b2e0000-2222-5aaa-8bbb-000000000001', + name: 'employment_income', + label: 'Employment income', + entity: 'person', + description: 'Wages and salaries', + data_type: 'float', + possible_values: null, + default_value: 0, + adds: null, + subtracts: null, + value_type: 'float', + definition_period: 'year', + unit: 'currency-USD', + module_name: 'household.income.person.employment_income', + is_input_variable: true, + hidden_input: false, + possible_value_labels: null, + }, + { + id: '7b2e0000-2222-5aaa-8bbb-000000000002', + name: 'state_name', + label: 'State', + entity: 'household', + description: null, + data_type: 'str', + possible_values: ['AL', 'CA'], + default_value: 'CA', + adds: null, + subtracts: null, + value_type: 'Enum', + definition_period: 'year', + unit: null, + module_name: 'household.demographic.geographic.state_name', + is_input_variable: true, + hidden_input: false, + possible_value_labels: { AL: 'Alabama', CA: 'California' }, + }, + { + id: '7b2e0000-2222-5aaa-8bbb-000000000003', + name: 'household_net_income', + label: 'Net income', + entity: 'household', + description: null, + data_type: 'float', + possible_values: null, + default_value: 0, + adds: ['household_market_income'], + subtracts: ['household_tax'], + value_type: 'float', + definition_period: 'year', + unit: 'currency-USD', + module_name: 'household.income.household.household_net_income', + is_input_variable: false, + hidden_input: false, + possible_value_labels: null, + }, + ], + ...overrides, + }; +} + +function region( + code: string, + label: string, + regionType: Schemas['MetadataRegionType'], + extra: Partial = {} +): Schemas['MetadataRegion'] { + return { + id: `8c3f0000-3333-5aaa-8bbb-${String(code.length).padStart(12, '0')}`, + code, + label, + region_type: regionType, + requires_filter: regionType !== 'national', + filter_field: null, + filter_value: null, + filter_strategy: null, + parent_code: null, + state_code: null, + state_name: null, + default_dataset_id: '9d4a0000-4444-5aaa-8bbb-000000000001', + ...extra, + }; +} + +/** Regions in the API's order (by code), including a place v1 never listed. */ +export const V2_US_REGIONS: Schemas['MetadataRegion'][] = [ + region( + 'congressional_district/CA-01', + "California's 1st congressional district", + 'congressional_district', + { + state_code: 'CA', + state_name: 'California', + } + ), + region('place/CA-44000', 'Los Angeles', 'place', { state_code: 'CA', state_name: 'California' }), + region('state/ca', 'California', 'state', { state_code: 'CA', state_name: 'California' }), + region('us', 'United States', 'national'), +]; diff --git a/app/src/tests/unit/api/publicApi/metadata.test.ts b/app/src/tests/unit/api/publicApi/metadata.test.ts new file mode 100644 index 000000000..ea9f56f1d --- /dev/null +++ b/app/src/tests/unit/api/publicApi/metadata.test.ts @@ -0,0 +1,292 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { createPublicApiClient } from '@/api/publicApi/client'; +import { fetchV2Metadata } from '@/api/publicApi/metadata'; +import { isV2ApiError } from '@/api/v2UserClient'; +import { + modelCatalog, + modelSelection, + V2_MODEL_ID, + V2_PARAMETER_IDS, + V2_POLICYENGINE_VERSION, + V2_US_REGIONS, +} from '@/tests/fixtures/api/publicApi/metadataV2Mocks'; +import { errorResponse, okResponse, TEST_API_BASE } from '@/tests/fixtures/api/v2UserMocks'; + +const CTC_AMOUNT = 'gov.irs.credits.ctc.amount.base[0].amount'; +const CTC_PHASE_OUT = 'gov.irs.credits.ctc.phase_out.threshold'; + +const fetchMock = vi.fn(); + +interface Answers { + selection?: Response; + catalog?: unknown; + regionPages?: unknown[][]; +} + +function answer({ selection, catalog = modelCatalog(), regionPages }: Answers = {}) { + const pages = regionPages ?? [V2_US_REGIONS]; + fetchMock.mockImplementation(async (request: Request) => { + const url = new URL(request.url); + if (url.pathname === '/v2/tax-benefit-models/by-country/us') { + return selection ?? okResponse(modelSelection()); + } + if (url.pathname === '/v2/model-catalog') { + return okResponse(catalog); + } + if (url.pathname === '/v2/regions') { + const pageIndex = Number(url.searchParams.get('offset') ?? 0) / 500; + return okResponse({ + policyengine_version: V2_POLICYENGINE_VERSION, + items: pages[pageIndex], + offset: pageIndex * 500, + limit: 500, + has_more: pageIndex < pages.length - 1, + }); + } + return errorResponse(404, 'not found'); + }); +} + +function load() { + return fetchV2Metadata( + 'us', + createPublicApiClient({ + baseUrl: TEST_API_BASE, + fetchImpl: fetchMock as unknown as typeof fetch, + }) + ); +} + +function requestedUrls(): URL[] { + return fetchMock.mock.calls.map(([request]) => new URL((request as Request).url)); +} + +beforeEach(() => { + fetchMock.mockReset(); +}); + +describe('fetchV2Metadata', () => { + test('given a country then the catalog and regions are read for its pinned version', async () => { + // Given + answer({ regionPages: [V2_US_REGIONS.slice(0, 2), V2_US_REGIONS.slice(2)] }); + + // When + await load(); + + // Then + const [selectionUrl, ...laterUrls] = requestedUrls(); + expect(selectionUrl.pathname).toBe('/v2/tax-benefit-models/by-country/us'); + expect(laterUrls.map((url) => url.pathname).sort()).toEqual([ + '/v2/model-catalog', + '/v2/regions', + '/v2/regions', + ]); + expect( + laterUrls.every( + (url) => + url.searchParams.get('country_id') === 'us' && + url.searchParams.get('policyengine_version') === V2_POLICYENGINE_VERSION + ) + ).toBe(true); + expect( + laterUrls + .filter((url) => url.pathname === '/v2/regions') + .map((url) => [url.searchParams.get('offset'), url.searchParams.get('limit')]) + ).toEqual([ + ['0', '500'], + ['500', '500'], + ]); + }); + + test('given the model version then the summary fields match the v1 document', async () => { + // Given + answer(); + + // When + const { result } = await load(); + + // Then + expect(result.version).toBe(V2_POLICYENGINE_VERSION); + expect(result.current_law_id).toBe(2); + expect(result.basicInputs).toEqual(['state_name', 'employment_income', 'age']); + expect(result.economy_options.time_period).toEqual([ + { name: 2027, label: '2027' }, + { name: 2026, label: '2026' }, + { name: 2025, label: '2025' }, + ]); + expect(result.entities).toEqual({ + person: { + key: 'person', + plural: 'people', + label: 'Person', + doc: 'An individual.', + is_person: true, + roles: {}, + }, + tax_unit: { + key: 'tax_unit', + plural: 'tax_units', + label: 'Tax unit', + doc: 'A tax unit.', + is_person: false, + roles: { member: { plural: 'members', label: 'Member', doc: null } }, + }, + }); + }); + + test('given catalog parameters then government parameters and nodes keep v1 labels', async () => { + // Given + answer(); + + // When + const { result } = await load(); + + // Then + const { parameters } = result; + expect(Object.keys(parameters).some((name) => name.startsWith('calibration'))).toBe(false); + expect(parameters[CTC_AMOUNT]).toEqual({ + type: 'parameter', + parameter: CTC_AMOUNT, + label: 'CTC base amount', + description: 'Maximum credit per child', + unit: 'currency-USD', + period: 'year', + values: { '2024-01-01': 2000, '2026-01-01': 2200 }, + economy: true, + household: true, + }); + expect(parameters[CTC_PHASE_OUT]).toMatchObject({ + label: 'threshold', + economy: false, + household: true, + values: { '2018-01-01': 'Infinity' }, + }); + expect(parameters['gov.irs.credits.ctc']).toEqual({ + type: 'parameterNode', + parameter: 'gov.irs.credits.ctc', + label: 'Child Tax Credit', + description: null, + }); + expect(parameters['gov.irs.income.bracket'].label).toBe('bracket'); + expect(parameters['gov.irs.income.bracket.rates[0]'].label).toBe('bracket 1'); + }); + + test('given catalog variables then each has the v1 field names', async () => { + // Given + answer(); + + // When + const { result } = await load(); + + // Then + expect(result.variables.employment_income).toEqual({ + name: 'employment_income', + label: 'Employment income', + entity: 'person', + documentation: 'Wages and salaries', + valueType: 'float', + definitionPeriod: 'year', + unit: 'currency-USD', + moduleName: 'household.income.person.employment_income', + isInputVariable: true, + hidden_input: false, + defaultValue: 0, + adds: null, + subtracts: null, + }); + expect(result.variables.state_name).toMatchObject({ + valueType: 'Enum', + defaultValue: 'CA', + possibleValues: [ + { value: 'AL', label: 'Alabama' }, + { value: 'CA', label: 'California' }, + ], + }); + expect(result.variables.household_net_income).toMatchObject({ + adds: ['household_market_income'], + subtracts: ['household_tax'], + isInputVariable: false, + }); + }); + + test('given catalog regions then v1 region types are listed, national first', async () => { + // Given + answer(); + + // When + const { result } = await load(); + + // Then + expect(result.economy_options.region).toEqual([ + { name: 'us', label: 'United States', type: 'national' }, + { name: 'state/ca', label: 'California', type: 'state' }, + { + name: 'congressional_district/CA-01', + label: "California's 1st congressional district", + type: 'congressional_district', + state_abbreviation: 'CA', + state_name: 'California', + }, + ]); + }); + + test('given the catalog then v2 saved reforms can name its model and parameters', async () => { + // Given + answer(); + + // When + const { result } = await load(); + + // Then + expect(result.v2Catalog).toEqual({ + policyengineVersion: V2_POLICYENGINE_VERSION, + taxBenefitModelId: V2_MODEL_ID, + parameterIds: { + [CTC_AMOUNT]: V2_PARAMETER_IDS.CTC_AMOUNT, + [CTC_PHASE_OUT]: V2_PARAMETER_IDS.CTC_PHASE_OUT, + 'gov.irs.income.bracket.rates[0].rate': V2_PARAMETER_IDS.BRACKET_RATE, + }, + }); + }); + + test('given a catalog published before format 2 then defaults stand in for absent fields', async () => { + // Given: no entities, basic inputs, flags, metadata labels or input flags + const olderCatalog = modelCatalog(); + olderCatalog.parameters = olderCatalog.parameters.map((parameter) => ({ + ...parameter, + metadata_label: null, + period: null, + economy: null, + household: null, + })); + answer({ + selection: okResponse( + modelSelection({ catalog_format: null, entities: null, basic_inputs: null }) + ), + catalog: olderCatalog, + }); + + // When + const { result } = await load(); + + // Then + expect(result.entities).toEqual({}); + expect(result.basicInputs).toEqual([]); + expect(result.parameters[CTC_AMOUNT]).toMatchObject({ + label: 'Child Tax Credit base amount (bracket 1 amount)', + economy: true, + household: true, + }); + }); + + test('given an unavailable catalog then the API error is thrown', async () => { + // Given + answer({ selection: errorResponse(503, 'V2 metadata catalog is unavailable') }); + + // When + const loading = load(); + + // Then + await expect(loading).rejects.toSatisfy((error: unknown) => isV2ApiError(error, 503)); + }); +}); diff --git a/app/src/tests/unit/api/reformStoreV2.test.ts b/app/src/tests/unit/api/reformStoreV2.test.ts new file mode 100644 index 000000000..79458603a --- /dev/null +++ b/app/src/tests/unit/api/reformStoreV2.test.ts @@ -0,0 +1,390 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPublicApiClient } from '@/api/publicApi/client'; +import { + ApiReformStore, + ApiV2ReformStore, + createV2ReformPolicy, + getReformStore, + LocalStorageReformStore, + parametersFromV2Policy, +} from '@/api/reformStore'; +import { FOREVER } from '@/constants'; +import { + V2_MODEL_ID, + V2_PARAMETER_IDS, + V2_POLICYENGINE_VERSION, +} from '@/tests/fixtures/api/publicApi/metadataV2Mocks'; +import { + ASSOCIATION_IDS, + createTestApiContext, + fetchCall, + itemResponse, + noContentResponse, + okResponse, + pageResponse, + policyItem, + TEST_API_BASE, +} from '@/tests/fixtures/api/v2UserMocks'; +import type { Reform } from '@/types/ingredients/Reform'; +import type { V2CatalogReference } from '@/types/metadata'; + +const CTC_AMOUNT = 'gov.irs.credits.ctc.amount.base[0].amount'; +const CTC_PHASE_OUT = 'gov.irs.credits.ctc.phase_out.threshold'; + +const V2_POLICY_ID = 'a1b2c3d4-0000-4000-8000-000000000001'; +const REPLACEMENT_V2_POLICY_ID = 'a1b2c3d4-0000-4000-8000-000000000002'; +const LEGACY_ONLY_POLICY_ID = 95762; + +const CATALOG: V2CatalogReference = { + policyengineVersion: V2_POLICYENGINE_VERSION, + taxBenefitModelId: V2_MODEL_ID, + parameterIds: { + [CTC_AMOUNT]: V2_PARAMETER_IDS.CTC_AMOUNT, + [CTC_PHASE_OUT]: V2_PARAMETER_IDS.CTC_PHASE_OUT, + }, +}; + +const REFORM_INPUT: Omit = { + countryId: 'us', + label: 'CTC to $3,600', + parameters: [ + { + name: CTC_AMOUNT, + values: [{ startDate: '2026-01-01', endDate: FOREVER, value: 3600 }], + }, + ], + baseline: 'current-law', + provenance: { source: 'manual' }, +}; + +/** The v2 policy as GET /v2/policies/{id} returns it. */ +function v2Policy(policyId = V2_POLICY_ID) { + return { + id: policyId, + country_id: 'us', + tax_benefit_model_id: V2_MODEL_ID, + created_at: '2026-10-20T10:00:00Z', + updated_at: '2026-10-20T10:00:00Z', + parameter_values: [ + { + id: 'b0000000-0000-4000-8000-000000000001', + parameter_id: V2_PARAMETER_IDS.CTC_AMOUNT, + parameter_name: CTC_AMOUNT, + value: 3600, + start_date: '2026-01-01T00:00:00Z', + end_date: '2100-12-31T00:00:00Z', + }, + { + id: 'b0000000-0000-4000-8000-000000000002', + parameter_id: V2_PARAMETER_IDS.CTC_PHASE_OUT, + parameter_name: CTC_PHASE_OUT, + value: 'Infinity', + start_date: '2027-01-01T00:00:00Z', + end_date: null, + }, + { + id: 'b0000000-0000-4000-8000-000000000003', + parameter_id: V2_PARAMETER_IDS.CTC_PHASE_OUT, + parameter_name: CTC_PHASE_OUT, + value: 400000, + start_date: '2026-01-01T00:00:00Z', + end_date: '2026-12-31T00:00:00Z', + }, + ], + }; +} + +const V2_REFORM_ROW = policyItem({ + id: ASSOCIATION_IDS.REFORM, + policy_id: V2_POLICY_ID, + legacy_policy_id: null, + name: 'CTC to $3,600', + baseline: 'current-law', + provenance: { source: 'manual', ref: null, confidence: null }, +}); + +function v2Store(catalog: V2CatalogReference | null = CATALOG) { + const { fetchMock, context } = createTestApiContext('us'); + const createPolicy = vi.fn().mockResolvedValue(V2_POLICY_ID); + const fetchV2Policy = vi.fn().mockResolvedValue(v2Policy()); + const fetchPolicyJson = vi.fn().mockResolvedValue({ + [CTC_AMOUNT]: { '2026-01-01.2100-12-31': 3000 }, + }); + const store = new ApiV2ReformStore(context, { + getV2Catalog: () => catalog, + createV2Policy: createPolicy, + fetchV2Policy, + fetchPolicyJson, + }); + return { store, fetchMock, createPolicy, fetchV2Policy, fetchPolicyJson }; +} + +describe('createV2ReformPolicy', () => { + it('given parameters then POST /v2/policies names catalog UUIDs at UTC midnight', async () => { + // Given + const fetchMock = vi.fn().mockResolvedValue(okResponse({ item: v2Policy() }, 201)); + const client = createPublicApiClient({ + baseUrl: TEST_API_BASE, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + const parameters = [ + ...REFORM_INPUT.parameters, + { + name: CTC_PHASE_OUT, + values: [{ startDate: '2027-01-01', endDate: FOREVER, value: Infinity }], + }, + ]; + + // When + const policyId = await createV2ReformPolicy('us', parameters, CATALOG, undefined, client); + + // Then + expect(policyId).toBe(V2_POLICY_ID); + const request = fetchMock.mock.calls[0][0] as Request; + const url = new URL(request.url); + expect(request.method).toBe('POST'); + expect(url.pathname).toBe('/v2/policies'); + expect(url.searchParams.get('country_id')).toBe('us'); + expect(url.searchParams.get('policyengine_version')).toBe(V2_POLICYENGINE_VERSION); + expect(await request.json()).toEqual({ + country_id: 'us', + tax_benefit_model_id: V2_MODEL_ID, + parameter_values: [ + { + parameter_id: V2_PARAMETER_IDS.CTC_AMOUNT, + value: 3600, + start_date: '2026-01-01T00:00:00Z', + end_date: '2100-12-31T00:00:00Z', + }, + { + parameter_id: V2_PARAMETER_IDS.CTC_PHASE_OUT, + value: 'Infinity', + start_date: '2027-01-01T00:00:00Z', + end_date: '2100-12-31T00:00:00Z', + }, + ], + }); + }); + + it('given a parameter the catalog lacks then nothing is sent', async () => { + // Given + const fetchMock = vi.fn(); + const client = createPublicApiClient({ + baseUrl: TEST_API_BASE, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + + // When + const creation = createV2ReformPolicy( + 'us', + [{ name: 'gov.contrib.unlisted', values: REFORM_INPUT.parameters[0].values }], + CATALOG, + undefined, + client + ); + + // Then + await expect(creation).rejects.toThrow('gov.contrib.unlisted'); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('parametersFromV2Policy', () => { + it('given a v2 policy then each parameter lists its dated values, oldest first', () => { + expect(parametersFromV2Policy(v2Policy())).toEqual([ + { + name: CTC_AMOUNT, + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 3600 }], + }, + { + name: CTC_PHASE_OUT, + values: [ + { startDate: '2026-01-01', endDate: '2026-12-31', value: 400000 }, + { startDate: '2027-01-01', endDate: FOREVER, value: 'Infinity' }, + ], + }, + ]); + }); +}); + +describe('ApiV2ReformStore', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('given a reform then its parameters become a v2 policy and the row links policy_id', async () => { + // Given + const { store, fetchMock, createPolicy } = v2Store(); + fetchMock.mockResolvedValue(itemResponse(V2_REFORM_ROW, 201)); + + // When + const created = await store.create(REFORM_INPUT); + + // Then + expect(createPolicy).toHaveBeenCalledWith('us', REFORM_INPUT.parameters, CATALOG, undefined); + const call = fetchCall(fetchMock); + expect(call.path).toBe('/v2/user-policies'); + expect(call.body).toEqual({ + country_id: 'us', + policy_id: V2_POLICY_ID, + name: 'CTC to $3,600', + baseline: 'current-law', + provenance: { source: 'manual', ref: null, confidence: null }, + }); + expect(created).toMatchObject({ + id: ASSOCIATION_IDS.REFORM, + policyId: V2_POLICY_ID, + parameters: REFORM_INPUT.parameters, + }); + }); + + it('given a reform already on a v2 policy then no new policy is created', async () => { + // Given + const { store, fetchMock, createPolicy } = v2Store(); + fetchMock.mockResolvedValue(itemResponse(V2_REFORM_ROW, 201)); + + // When + await store.create({ ...REFORM_INPUT, policyId: V2_POLICY_ID }); + + // Then + expect(createPolicy).not.toHaveBeenCalled(); + expect(fetchCall(fetchMock).body.policy_id).toBe(V2_POLICY_ID); + }); + + it('given no v2 catalog loaded then saving fails before any request', async () => { + // Given + const { store, fetchMock, createPolicy } = v2Store(null); + + // When + const creation = store.create(REFORM_INPUT); + + // Then + await expect(creation).rejects.toThrow('model'); + expect(createPolicy).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('given saved rows then v2 rows read their policy and v1-only rows read the v1 policy', async () => { + // Given + const { store, fetchMock, fetchV2Policy, fetchPolicyJson } = v2Store(); + const legacyRow = policyItem({ + id: '66666666-6666-4666-8666-666666666666', + policy_id: null, + legacy_policy_id: LEGACY_ONLY_POLICY_ID, + provenance: { source: 'bill', ref: 'HR-1', confidence: null }, + updated_at: '2026-10-01T00:00:00+00:00', + }); + fetchMock.mockResolvedValue( + pageResponse([ + V2_REFORM_ROW, + legacyRow, + policyItem({ id: 'legacy-builder', provenance: null }), + ]) + ); + + // When + const reforms = await store.findMine('us'); + + // Then + expect(fetchV2Policy).toHaveBeenCalledWith('us', V2_POLICY_ID); + expect(fetchPolicyJson).toHaveBeenCalledWith('us', String(LEGACY_ONLY_POLICY_ID)); + expect(reforms.map((reform) => [reform.id, reform.policyId])).toEqual([ + [ASSOCIATION_IDS.REFORM, V2_POLICY_ID], + [legacyRow.id, String(LEGACY_ONLY_POLICY_ID)], + ]); + expect(reforms[0].parameters).toEqual(parametersFromV2Policy(v2Policy())); + expect(reforms[1].parameters).toEqual([ + { + name: CTC_AMOUNT, + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 3000 }], + }, + ]); + }); + + it('given a row whose policy fails to load then it is listed without its changes', async () => { + // Given + const { store, fetchMock, fetchV2Policy } = v2Store(); + fetchV2Policy.mockRejectedValue(new Error('Policy not found')); + fetchMock.mockResolvedValue(pageResponse([V2_REFORM_ROW])); + + // When + const [reform] = await store.findMine('us'); + + // Then + expect(reform.parametersUnavailable).toBe(true); + expect(reform.parameters).toEqual([]); + }); + + it('given changed parameters then a new v2 policy is created and the row is repointed', async () => { + // Given + const { store, fetchMock, createPolicy } = v2Store(); + createPolicy.mockResolvedValue(REPLACEMENT_V2_POLICY_ID); + fetchMock.mockResolvedValue( + itemResponse({ ...V2_REFORM_ROW, policy_id: REPLACEMENT_V2_POLICY_ID }) + ); + const changed = [ + { name: CTC_AMOUNT, values: [{ startDate: '2026-01-01', endDate: FOREVER, value: 4000 }] }, + ]; + + // When + const updated = await store.update(ASSOCIATION_IDS.REFORM, { + parameters: changed, + label: 'CTC to $4,000', + }); + + // Then + const call = fetchCall(fetchMock); + expect(call.method).toBe('PATCH'); + expect(call.path).toBe(`/v2/user-policies/${ASSOCIATION_IDS.REFORM}`); + expect(call.body).toEqual({ name: 'CTC to $4,000', policy_id: REPLACEMENT_V2_POLICY_ID }); + expect(updated.policyId).toBe(REPLACEMENT_V2_POLICY_ID); + expect(updated.parameters).toEqual(changed); + }); + + it('given only a new name then only the name is patched', async () => { + // Given + const { store, fetchMock, createPolicy } = v2Store(); + fetchMock.mockResolvedValue(itemResponse({ ...V2_REFORM_ROW, name: 'Renamed' })); + + // When + await store.update(ASSOCIATION_IDS.REFORM, { label: 'Renamed' }); + + // Then + expect(createPolicy).not.toHaveBeenCalled(); + expect(fetchCall(fetchMock).body).toEqual({ name: 'Renamed' }); + }); + + it('given a reform then deleting it removes only the row', async () => { + // Given + const { store, fetchMock } = v2Store(); + fetchMock.mockResolvedValue(noContentResponse()); + + // When + await store.delete(ASSOCIATION_IDS.REFORM); + + // Then + const call = fetchCall(fetchMock); + expect(call.method).toBe('DELETE'); + expect(call.path).toBe(`/v2/user-policies/${ASSOCIATION_IDS.REFORM}`); + }); +}); + +describe('getReformStore', () => { + it('given an account then the routes decide between the v1 and v2 stores', () => { + const { context } = createTestApiContext('us'); + + expect(getReformStore(context, {}, 'v1')).toBeInstanceOf(ApiReformStore); + expect(getReformStore(context, {}, 'v2')).toBeInstanceOf(ApiV2ReformStore); + }); + + it('given no account then reforms stay in this browser on either routes', () => { + expect(getReformStore(null, {}, 'v1')).toBeInstanceOf(LocalStorageReformStore); + expect(getReformStore(null, {}, 'v2')).toBeInstanceOf(LocalStorageReformStore); + }); + + it('given no routes then the store follows V3_API_ROUTES, which is v1 before the switch', () => { + const { context } = createTestApiContext('us'); + + expect(getReformStore(context)).toBeInstanceOf(ApiReformStore); + }); +}); diff --git a/app/src/tests/unit/hooks/useFlagshipMetadataStatusV2.test.tsx b/app/src/tests/unit/hooks/useFlagshipMetadataStatusV2.test.tsx new file mode 100644 index 000000000..5d6ee2799 --- /dev/null +++ b/app/src/tests/unit/hooks/useFlagshipMetadataStatusV2.test.tsx @@ -0,0 +1,71 @@ +import type { ReactNode } from 'react'; +import { act, renderHook } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { CountryProvider } from '@/contexts/CountryContext'; +import { useFlagshipMetadataStatus } from '@/hooks/useFlagshipMetadataStatus'; +import { clearMetadata } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { seedDeductionMetadata } from '@/tests/fixtures/components/flagship/draftEditorFixtures'; +import { seedMetadataError } from '@/tests/fixtures/hooks/useFlagshipMetadataStatusMocks'; + +/** v3 after the switch: V3_API_ROUTES is 'v2'. */ +vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ + ...(await importOriginal()), + V3_API_ROUTES: 'v2', +})); + +const mockFetchV1Metadata = vi.fn(); +const mockFetchV2Metadata = vi.fn(); + +vi.mock('@/api/metadata', async (importOriginal) => ({ + ...(await importOriginal()), + fetchMetadata: (...args: unknown[]) => mockFetchV1Metadata(...args), +})); + +vi.mock('@/api/publicApi/metadata', async (importOriginal) => ({ + ...(await importOriginal()), + fetchV2Metadata: (...args: unknown[]) => mockFetchV2Metadata(...args), +})); + +const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +describe('useFlagshipMetadataStatus with v3 on the v2 routes', () => { + beforeEach(() => { + store.dispatch(clearMetadata()); + mockFetchV1Metadata.mockReset(); + mockFetchV2Metadata.mockReset(); + mockFetchV2Metadata.mockReturnValue(new Promise(() => {})); + }); + + test('given metadata from the v1 route then v3 is not ready yet', () => { + // Given: the legacy calculator loaded v1 metadata before the user opened v3 + seedDeductionMetadata(); + + // When + const { result } = renderHook(() => useFlagshipMetadataStatus(), { wrapper }); + + // Then + expect(result.current.status).toBe('loading'); + }); + + test('given a failed fetch then retry asks the v2 routes', () => { + // Given + seedMetadataError(); + const { result } = renderHook(() => useFlagshipMetadataStatus(), { wrapper }); + + // When + act(() => { + result.current.retry(); + }); + + // Then + expect(mockFetchV2Metadata).toHaveBeenCalledWith('us'); + expect(mockFetchV1Metadata).not.toHaveBeenCalled(); + expect(result.current.status).toBe('loading'); + }); +}); diff --git a/app/src/tests/unit/hooks/useFlagshipStoresV2.test.tsx b/app/src/tests/unit/hooks/useFlagshipStoresV2.test.tsx new file mode 100644 index 000000000..dfdd51322 --- /dev/null +++ b/app/src/tests/unit/hooks/useFlagshipStoresV2.test.tsx @@ -0,0 +1,189 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react'; +import { Provider as ReduxProvider } from 'react-redux'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { ApiV2ReformStore, LocalStorageReformStore } from '@/api/reformStore'; +import { CountryProvider } from '@/contexts/CountryContext'; +import { useReformStore } from '@/hooks/useFlagshipStores'; +import type { AuthContextValue } from '@/libs/auth/AuthContext'; +import { fetchMetadataThunk } from '@/reducers/metadataReducer'; +import { store } from '@/store'; +import { + V2_MODEL_ID, + V2_PARAMETER_IDS, + V2_POLICYENGINE_VERSION, +} from '@/tests/fixtures/api/publicApi/metadataV2Mocks'; +import { + ASSOCIATION_IDS, + okResponse, + pageResponse, + policyItem, +} from '@/tests/fixtures/api/v2UserMocks'; +import { + AuthTestProvider, + makeAuthValue, + signedInAuthValue, +} from '@/tests/fixtures/libs/auth/authMocks'; + +/** v3 after the switch: V3_API_ROUTES is 'v2'. */ +vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => ({ + ...(await importOriginal()), + V3_API_ROUTES: 'v2', +})); + +const CTC_AMOUNT = 'gov.irs.credits.ctc.amount.base[0].amount'; +const V2_POLICY_ID = 'a1b2c3d4-0000-4000-8000-000000000001'; + +const V2_REFORM_ROW = policyItem({ + id: ASSOCIATION_IDS.REFORM, + policy_id: V2_POLICY_ID, + legacy_policy_id: null, + name: 'CTC to $3,600', + baseline: 'current-law', + provenance: { source: 'manual', ref: null, confidence: null }, +}); + +const fetchMock = vi.fn(); + +function requestUrl(input: unknown): URL { + return new URL(input instanceof Request ? input.url : String(input)); +} + +/** Answers the account's reform list and the reform's v2 policy. */ +function respond(input: unknown): Response { + if (requestUrl(input).pathname === `/v2/policies/${V2_POLICY_ID}`) { + return okResponse({ + item: { + id: V2_POLICY_ID, + country_id: 'us', + tax_benefit_model_id: V2_MODEL_ID, + created_at: '2026-10-20T10:00:00Z', + updated_at: '2026-10-20T10:00:00Z', + parameter_values: [ + { + id: 'b0000000-0000-4000-8000-000000000001', + parameter_id: V2_PARAMETER_IDS.CTC_AMOUNT, + parameter_name: CTC_AMOUNT, + value: 3600, + start_date: '2026-01-01T00:00:00Z', + end_date: '2100-12-31T00:00:00Z', + }, + ], + }, + }); + } + return pageResponse([V2_REFORM_ROW]); +} + +function v2PolicyRequests(): number { + return fetchMock.mock.calls.filter( + ([input]) => requestUrl(input).pathname === `/v2/policies/${V2_POLICY_ID}` + ).length; +} + +/** Metadata loaded from the v2 routes, with its catalog identities. */ +function seedV2Metadata() { + const meta = { + arg: { countryId: 'us', routes: 'v2' }, + requestId: 'v2-store-test', + requestStatus: 'pending', + }; + store.dispatch({ type: fetchMetadataThunk.pending.type, meta }); + store.dispatch({ + type: fetchMetadataThunk.fulfilled.type, + meta: { ...meta, requestStatus: 'fulfilled' }, + payload: { + country: 'us', + data: { + status: 'ok', + message: null, + result: { + parameters: {}, + variables: {}, + entities: {}, + variableModules: {}, + economy_options: { region: [], time_period: [], datasets: [] }, + current_law_id: 2, + basicInputs: [], + modelled_policies: { core: {}, filtered: {} }, + version: V2_POLICYENGINE_VERSION, + v2Catalog: { + policyengineVersion: V2_POLICYENGINE_VERSION, + taxBenefitModelId: V2_MODEL_ID, + parameterIds: { [CTC_AMOUNT]: V2_PARAMETER_IDS.CTC_AMOUNT }, + }, + }, + }, + }, + }); +} + +function wrapperFor(auth: AuthContextValue, countryId: 'us' | 'ca' = 'us') { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ({ children }: { children: React.ReactNode }) => ( + + + + {children} + + + + ); +} + +beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockImplementation(async (input: unknown) => respond(input)); + vi.stubGlobal('fetch', fetchMock); + seedV2Metadata(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('useReformStore with v3 on the v2 routes', () => { + test.each([ + ['signed in', signedInAuthValue()], + ['signed out', makeAuthValue()], + ] as const)('given %s in the US then reforms use v2 policies', (_, auth) => { + // When + const reforms = renderHook(() => useReformStore(), { wrapper: wrapperFor(auth) }).result + .current; + + // Then + expect(reforms).toBeInstanceOf(ApiV2ReformStore); + }); + + test('given a country without accounts then reforms stay in this browser', () => { + // When + const reforms = renderHook(() => useReformStore(), { + wrapper: wrapperFor(signedInAuthValue(), 'ca'), + }).result.current; + + // Then + expect(reforms).toBeInstanceOf(LocalStorageReformStore); + }); + + test('given the reform list is loaded again then each v2 policy is fetched only once', async () => { + // Given + const reforms = renderHook(() => useReformStore(), { + wrapper: wrapperFor(signedInAuthValue()), + }).result.current; + + // When + const first = await reforms.findMine('us'); + const second = await reforms.findMine('us'); + + // Then + expect(v2PolicyRequests()).toBe(1); + expect(first[0].parameters).toEqual([ + { + name: CTC_AMOUNT, + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 3600 }], + }, + ]); + expect(second[0].parameters).toEqual(first[0].parameters); + }); +}); diff --git a/app/src/tests/unit/hooks/useMetadata.test.tsx b/app/src/tests/unit/hooks/useMetadata.test.tsx index 58573a392..bb30f5fcf 100644 --- a/app/src/tests/unit/hooks/useMetadata.test.tsx +++ b/app/src/tests/unit/hooks/useMetadata.test.tsx @@ -18,11 +18,14 @@ import { // Mock must be before imports that use it vi.mock('@/reducers/metadataReducer', () => ({ - fetchMetadataThunk: vi.fn((country: string) => { - const thunk = () => ({ type: 'metadata/fetch/pending', meta: { arg: country } }); - thunk.country = country; // Add country as property for testing + fetchMetadataThunk: vi.fn((request: string | { countryId: string; routes: string }) => { + const thunk = () => ({ type: 'metadata/fetch/pending', meta: { arg: request } }); + // Record a v1 request as its country, as before, and a v2 one by its routes. + thunk.country = typeof request === 'string' ? request : `${request.countryId}:v2`; return thunk; }), + metadataRequest: (countryId: string, routes: string) => + routes === 'v1' ? countryId : { countryId, routes }, default: (state = {}) => state, })); @@ -203,4 +206,32 @@ describe('useFetchMetadata', () => { // Then - unmount should not throw expect(() => unmount()).not.toThrow(); }); + + describe('metadata routes', () => { + test('given v1 metadata loaded and v2 requested then the v2 routes are fetched', () => { + // Given + const { wrapper } = createTestSetup({ ...mockLoadedMetadataState, routes: 'v1' }); + + // When + renderHook(() => useFetchMetadata(mockLoadedMetadataState.currentCountry!, 'v2'), { + wrapper, + }); + + // Then + expect(dispatchedThunks).toEqual([`${mockLoadedMetadataState.currentCountry}:v2`]); + }); + + test('given v2 metadata loaded and v2 requested then nothing is fetched', () => { + // Given + const { wrapper } = createTestSetup({ ...mockLoadedMetadataState, routes: 'v2' }); + + // When + renderHook(() => useFetchMetadata(mockLoadedMetadataState.currentCountry!, 'v2'), { + wrapper, + }); + + // Then + expect(dispatchedThunks).toEqual([]); + }); + }); }); diff --git a/app/src/tests/unit/libs/flagship/v3ApiRoutes.test.ts b/app/src/tests/unit/libs/flagship/v3ApiRoutes.test.ts new file mode 100644 index 000000000..886f1e342 --- /dev/null +++ b/app/src/tests/unit/libs/flagship/v3ApiRoutes.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from 'vitest'; +import { metadataRoutesForRouteGroup, V3_API_ROUTES } from '@/libs/flagship/v3ApiRoutes'; + +describe('v3ApiRoutes', () => { + test('given production before the switch then v3 uses the v1 routes', () => { + // The switch pull request (plan item 4A-1) changes V3_API_ROUTES to 'v2' + // and this expectation with it. + expect(V3_API_ROUTES).toBe('v1'); + }); + + test('given the v3 workspace route group then metadata follows V3_API_ROUTES', () => { + expect(metadataRoutesForRouteGroup('(workspace)')).toBe(V3_API_ROUTES); + }); + + test('given the legacy calculator or no route group then metadata stays on v1', () => { + expect(metadataRoutesForRouteGroup('(calculator)')).toBe('v1'); + expect(metadataRoutesForRouteGroup(null)).toBe('v1'); + }); +}); diff --git a/app/src/tests/unit/reducers/metadataReducer.test.ts b/app/src/tests/unit/reducers/metadataReducer.test.ts index e86185292..73da0dfea 100644 --- a/app/src/tests/unit/reducers/metadataReducer.test.ts +++ b/app/src/tests/unit/reducers/metadataReducer.test.ts @@ -1,10 +1,12 @@ import { configureStore } from '@reduxjs/toolkit'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import * as metadataApi from '@/api/metadata'; +import * as v2MetadataApi from '@/api/publicApi/metadata'; import * as buildParameterTreeModule from '@/libs/buildParameterTree'; import metadataReducer, { clearMetadata, fetchMetadataThunk, + metadataRequest, setCurrentCountry, } from '@/reducers/metadataReducer'; import { @@ -38,6 +40,7 @@ import { // Mock the API and buildParameterTree modules vi.mock('@/api/metadata'); +vi.mock('@/api/publicApi/metadata'); vi.mock('@/libs/buildParameterTree'); describe('metadataReducer', () => { @@ -377,4 +380,82 @@ describe('metadataReducer', () => { expectEmptyMetadata(state); }); }); + + describe('metadata routes', () => { + const V2_CATALOG = { + policyengineVersion: '6.7.5', + taxBenefitModelId: '0b6c4f6e-7c55-5d47-9a51-4e9f5fbbf001', + parameterIds: { 'gov.tax.income_tax': '6a1f0c1e-1111-5aaa-8bbb-000000000001' }, + }; + + test('given a v2 request then the v2 routes are read and remembered', async () => { + // Given + const apiPayload = createMockApiPayload(); + apiPayload.result.v2Catalog = V2_CATALOG; + vi.mocked(v2MetadataApi.fetchV2Metadata).mockResolvedValue(apiPayload); + const store = configureStore({ reducer: { metadata: metadataReducer } }); + + // When + await store.dispatch(fetchMetadataThunk({ countryId: TEST_COUNTRY_US, routes: 'v2' })); + + // Then + expect(v2MetadataApi.fetchV2Metadata).toHaveBeenCalledWith(TEST_COUNTRY_US); + expect(metadataApi.fetchMetadata).not.toHaveBeenCalled(); + const state = store.getState().metadata; + expect(state.routes).toBe('v2'); + expect(state.v2Catalog).toEqual(V2_CATALOG); + expect(state.version).toBe(TEST_VERSION); + }); + + test('given a country alone then the v1 route is read, as before', async () => { + // Given + vi.mocked(metadataApi.fetchMetadata).mockResolvedValue(createMockApiPayload()); + const store = configureStore({ reducer: { metadata: metadataReducer } }); + + // When + await store.dispatch(fetchMetadataThunk(TEST_COUNTRY_US)); + + // Then + expect(metadataApi.fetchMetadata).toHaveBeenCalledWith(TEST_COUNTRY_US); + expect(v2MetadataApi.fetchV2Metadata).not.toHaveBeenCalled(); + const state = store.getState().metadata; + expect(state.routes).toBe('v1'); + expect(state.v2Catalog).toBeNull(); + }); + + test('given a pending v1 load then a v2 request runs once and wins', async () => { + // Given + let finishV2Load: (payload: ReturnType) => void = () => {}; + vi.mocked(metadataApi.fetchMetadata).mockReturnValue(new Promise(() => {})); + vi.mocked(v2MetadataApi.fetchV2Metadata).mockReturnValue( + new Promise((resolve) => { + finishV2Load = resolve; + }) + ); + const store = configureStore({ reducer: { metadata: metadataReducer } }); + store.dispatch(fetchMetadataThunk(TEST_COUNTRY_US)); + + // When: v2 is requested twice while it loads + const v2Load = store.dispatch( + fetchMetadataThunk({ countryId: TEST_COUNTRY_US, routes: 'v2' }) + ); + store.dispatch(fetchMetadataThunk({ countryId: TEST_COUNTRY_US, routes: 'v2' })); + finishV2Load(createMockApiPayload()); + await v2Load; + + // Then + expect(v2MetadataApi.fetchV2Metadata).toHaveBeenCalledTimes(1); + expect(store.getState().metadata.routes).toBe('v2'); + expect(store.getState().metadata.loading).toBe(false); + expect(store.getState().metadata.version).toBe(TEST_VERSION); + }); + + test('given routes then the request keeps the v1 form for v1', () => { + expect(metadataRequest(TEST_COUNTRY_UK, 'v1')).toBe(TEST_COUNTRY_UK); + expect(metadataRequest(TEST_COUNTRY_UK, 'v2')).toEqual({ + countryId: TEST_COUNTRY_UK, + routes: 'v2', + }); + }); + }); }); diff --git a/app/src/types/metadata.ts b/app/src/types/metadata.ts index c4476d78f..4b4ad0ede 100644 --- a/app/src/types/metadata.ts +++ b/app/src/types/metadata.ts @@ -19,6 +19,21 @@ export interface MetadataRegionEntry { state_name?: string; } +/** + * The v2 catalog identities a v2 saved reform needs to create policies + * (`POST /v2/policies` takes catalog UUIDs, not parameter paths). + */ +export interface V2CatalogReference { + /** The PolicyEngine.py version the catalog belongs to; policies are created for it. */ + policyengineVersion: string; + taxBenefitModelId: string; + /** Each government parameter's catalog UUID, by parameter path. */ + parameterIds: Record; +} + +/** Which API routes model metadata was loaded from. */ +export type MetadataRoutes = 'v1' | 'v2'; + export interface MetadataApiPayload { status: string; message: string | null; @@ -40,6 +55,8 @@ export interface MetadataApiPayload { }; version: string; spm?: SPMMetadata; + /** Set only when the metadata came from the v2 routes. */ + v2Catalog?: V2CatalogReference; }; } @@ -85,6 +102,10 @@ export interface MetadataState { }; version: string | null; spm?: SPMMetadata; + /** The routes the current (or pending) metadata comes from; absent means v1. */ + routes?: MetadataRoutes; + /** The v2 catalog identities, when the metadata came from the v2 routes. */ + v2Catalog?: V2CatalogReference | null; // Computed parameter tree for policy creation UI (built when metadata is fetched) parameterTree: ParameterTreeNode | null; diff --git a/app/src/types/v2User.ts b/app/src/types/v2User.ts index 63059ed0d..97d432c04 100644 --- a/app/src/types/v2User.ts +++ b/app/src/types/v2User.ts @@ -138,7 +138,10 @@ export interface SharedReportItem { export interface UserPolicyCreateBody { country_id: AssociationCountryId; - legacy_policy_id: number; + /** A v2 policy (`/v2/policies`); a row needs this or legacy_policy_id. */ + policy_id?: string; + /** A v1 policy (`POST /{country}/policy`). */ + legacy_policy_id?: number; name?: string | null; description?: string | null; baseline?: BaselineWire | null; @@ -148,7 +151,7 @@ export interface UserPolicyCreateBody { export type UserPolicyPatchBody = Partial< Pick< UserPolicyCreateBody, - 'legacy_policy_id' | 'name' | 'description' | 'baseline' | 'provenance' + 'policy_id' | 'legacy_policy_id' | 'name' | 'description' | 'baseline' | 'provenance' > >; diff --git a/calculator-app/src/app/[countryId]/providers.tsx b/calculator-app/src/app/[countryId]/providers.tsx index f2c0d6436..4eb869b00 100644 --- a/calculator-app/src/app/[countryId]/providers.tsx +++ b/calculator-app/src/app/[countryId]/providers.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import dynamic from "next/dynamic"; +import { useSelectedLayoutSegment } from "next/navigation"; import { QueryNormalizerProvider } from "@normy/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Provider, useDispatch } from "react-redux"; @@ -10,6 +11,7 @@ import { AppProvider } from "@/contexts/AppContext"; import { CalcOrchestratorProvider } from "@/contexts/CalcOrchestratorContext"; import { useCurrentCountry } from "@/hooks/useCurrentCountry"; import { useFetchMetadata } from "@/hooks/useMetadata"; +import { metadataRoutesForRouteGroup } from "@/libs/flagship/v3ApiRoutes"; import { setCurrentCountry } from "@/reducers/metadataReducer"; import { AppDispatch, store } from "@/store"; import { cacheMonitor } from "@/utils/cacheMonitor"; @@ -87,16 +89,19 @@ export function CalculatorProviders({ /** * Syncs the country to Redux and triggers a non-blocking metadata fetch. + * The v3 workspace route group loads from the routes V3_API_ROUTES names; + * the legacy calculator always loads from v1. */ function MetadataBootstrap({ children }: { children: React.ReactNode }) { const countryId = useCurrentCountry(); const dispatch = useDispatch(); + const routes = metadataRoutesForRouteGroup(useSelectedLayoutSegment()); useEffect(() => { dispatch(setCurrentCountry(countryId)); }, [countryId, dispatch]); - useFetchMetadata(countryId); + useFetchMetadata(countryId, routes); return <>{children}; } From 360583a32681317c69607e156fe5fb1e7ebd0912 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:23:05 +0400 Subject: [PATCH 2/5] Date v2 values in force from the earliest date as v1 did The v2 catalog stores the earliest start as 0001-01-01 (Python's first year); v1 metadata wrote 0000-01-01, which the parameter history chart leaves out. fetchV2Metadata now returns v1's spelling. A comparison of the real 6.7.5 catalog through the v2 routes with v1's metadata found this the only systematic difference in parameter values. Co-Authored-By: Claude Opus 5.5 --- app/src/api/publicApi/metadata.ts | 15 +++++++++++++- .../tests/unit/api/publicApi/metadata.test.ts | 20 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/app/src/api/publicApi/metadata.ts b/app/src/api/publicApi/metadata.ts index 98187123e..87e91dbc5 100644 --- a/app/src/api/publicApi/metadata.ts +++ b/app/src/api/publicApi/metadata.ts @@ -47,6 +47,19 @@ function v1FallbackLabel(path: string): string { return (path.split('.').pop() ?? path).replaceAll('_', ' '); } +/** v1 dated a value in force since the earliest date 0000-01-01; Python's earliest is year 1. */ +const V2_EARLIEST_DATE = '0001-01-01'; +const V1_EARLIEST_DATE = '0000-01-01'; + +function v1Values(values: Record): ValuesList { + return Object.fromEntries( + Object.entries(values).map(([date, value]) => [ + date === V2_EARLIEST_DATE ? V1_EARLIEST_DATE : date, + value, + ]) + ) as ValuesList; +} + function parameterCollection( catalog: Schemas['MetadataCatalogResult'], hasPresentationMetadata: boolean @@ -77,7 +90,7 @@ function parameterCollection( description: parameter.description, unit: parameter.unit, period: parameter.period ?? null, - values: parameter.values as ValuesList, + values: v1Values(parameter.values), economy: parameter.economy ?? true, household: parameter.household ?? true, }; diff --git a/app/src/tests/unit/api/publicApi/metadata.test.ts b/app/src/tests/unit/api/publicApi/metadata.test.ts index ea9f56f1d..d2641802b 100644 --- a/app/src/tests/unit/api/publicApi/metadata.test.ts +++ b/app/src/tests/unit/api/publicApi/metadata.test.ts @@ -171,6 +171,26 @@ describe('fetchV2Metadata', () => { expect(parameters['gov.irs.income.bracket.rates[0]'].label).toBe('bracket 1'); }); + test('given a value in force since the earliest date then v1 dates it 0000-01-01', async () => { + // Given: Python's earliest date is year 1, v1 wrote year 0 + const catalog = modelCatalog(); + catalog.parameters = catalog.parameters.map((parameter) => + parameter.name === CTC_PHASE_OUT + ? { ...parameter, values: { '0001-01-01': 75000, '2018-01-01': 'Infinity' } } + : parameter + ); + answer({ catalog }); + + // When + const { result } = await load(); + + // Then + expect(result.parameters[CTC_PHASE_OUT].values).toEqual({ + '0000-01-01': 75000, + '2018-01-01': 'Infinity', + }); + }); + test('given catalog variables then each has the v1 field names', async () => { // Given answer(); From 3438e4d3a5091f9ad0ca94e09db39875956f62c8 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:19:48 +0400 Subject: [PATCH 3/5] Test that v3 pages load v2 metadata and save to the loaded catalog Two parts of the v2 code had no test that fails without them: - The calculator's provider tree picks the metadata routes from the selected route group. Only the selection function was tested, and with V3_API_ROUTES at 'v1' its workspace case could not tell v1 from v2. metadataRoutesForRouteGroup now takes the v3 routes as an optional argument (defaulting to V3_API_ROUTES, so behaviour is unchanged), its tests name 'v2', and a new test renders CalculatorProviders on a workspace page and on a legacy calculator page. - useReformStore hands the store the v2 catalog from the metadata store. A new test saves a reform through the hook and checks the POST /v2/policies request names that catalog. The workspace case of the selection tests failed first (it returned 'v1' for 'v2'). The provider test fails when MetadataBootstrap stops passing the routes to useFetchMetadata, and the hook test fails when useReformStore stops passing getV2Catalog. Co-Authored-By: Claude Opus 5.5 --- app/src/libs/flagship/v3ApiRoutes.ts | 11 +- .../calculator/CalculatorProviders.test.tsx | 63 +++++++++++ .../unit/hooks/useFlagshipStoresV2.test.tsx | 105 ++++++++++++++---- .../unit/libs/flagship/v3ApiRoutes.test.ts | 11 +- 4 files changed, 160 insertions(+), 30 deletions(-) create mode 100644 app/src/tests/unit/calculator/CalculatorProviders.test.tsx diff --git a/app/src/libs/flagship/v3ApiRoutes.ts b/app/src/libs/flagship/v3ApiRoutes.ts index 539f70907..5d6a4b989 100644 --- a/app/src/libs/flagship/v3ApiRoutes.ts +++ b/app/src/libs/flagship/v3ApiRoutes.ts @@ -23,9 +23,12 @@ export const V3_WORKSPACE_ROUTE_GROUP = '(workspace)'; /** * Where model metadata loads from for a page in this route group (the - * segment below `[countryId]`): v3 follows V3_API_ROUTES; everything else - * stays on v1. + * segment below `[countryId]`): v3 follows `v3Routes` (V3_API_ROUTES unless + * a test names the routes after the switch); everything else stays on v1. */ -export function metadataRoutesForRouteGroup(routeGroup: string | null): V3ApiRoutes { - return routeGroup === V3_WORKSPACE_ROUTE_GROUP ? V3_API_ROUTES : 'v1'; +export function metadataRoutesForRouteGroup( + routeGroup: string | null, + v3Routes: V3ApiRoutes = V3_API_ROUTES +): V3ApiRoutes { + return routeGroup === V3_WORKSPACE_ROUTE_GROUP ? v3Routes : 'v1'; } diff --git a/app/src/tests/unit/calculator/CalculatorProviders.test.tsx b/app/src/tests/unit/calculator/CalculatorProviders.test.tsx new file mode 100644 index 000000000..280f5bd07 --- /dev/null +++ b/app/src/tests/unit/calculator/CalculatorProviders.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { CountryProvider } from '@/contexts/CountryContext'; +import { CalculatorProviders } from '../../../../../calculator-app/src/app/[countryId]/providers'; + +const selectedLayout = vi.hoisted(() => ({ routeGroup: null as string | null })); +const mockUseFetchMetadata = vi.hoisted(() => vi.fn()); + +vi.mock('next/navigation', () => ({ + useSelectedLayoutSegment: () => selectedLayout.routeGroup, +})); + +vi.mock('@/hooks/useMetadata', () => ({ + useFetchMetadata: mockUseFetchMetadata, +})); + +// Outside production the providers load the journey profiler, which needs a window. +vi.mock('@/utils/journeyProfiler', () => ({})); + +/** v3 after the switch: the workspace route group reads the v2 routes. */ +vi.mock('@/libs/flagship/v3ApiRoutes', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + V3_API_ROUTES: 'v2', + metadataRoutesForRouteGroup: (routeGroup: string | null) => + original.metadataRoutesForRouteGroup(routeGroup, 'v2'), + }; +}); + +function renderCountryPage(routeGroup: string | null) { + selectedLayout.routeGroup = routeGroup; + render( + + +

Country page

+
+
+ ); +} + +describe('CalculatorProviders metadata routes', () => { + beforeEach(() => { + mockUseFetchMetadata.mockClear(); + }); + + test('given a v3 workspace page then metadata is fetched from the v3 routes', () => { + // When + renderCountryPage('(workspace)'); + + // Then + expect(screen.getByText('Country page')).toBeInTheDocument(); + expect(mockUseFetchMetadata).toHaveBeenLastCalledWith('us', 'v2'); + }); + + test('given a legacy calculator page then metadata is fetched from v1', () => { + // When + renderCountryPage('(calculator)'); + + // Then + expect(mockUseFetchMetadata).toHaveBeenLastCalledWith('us', 'v1'); + }); +}); diff --git a/app/src/tests/unit/hooks/useFlagshipStoresV2.test.tsx b/app/src/tests/unit/hooks/useFlagshipStoresV2.test.tsx index dfdd51322..1a511201e 100644 --- a/app/src/tests/unit/hooks/useFlagshipStoresV2.test.tsx +++ b/app/src/tests/unit/hooks/useFlagshipStoresV2.test.tsx @@ -16,6 +16,7 @@ import { } from '@/tests/fixtures/api/publicApi/metadataV2Mocks'; import { ASSOCIATION_IDS, + itemResponse, okResponse, pageResponse, policyItem, @@ -44,38 +45,63 @@ const V2_REFORM_ROW = policyItem({ provenance: { source: 'manual', ref: null, confidence: null }, }); +/** The reform's v2 policy, as `/v2/policies` returns it. */ +const V2_POLICY_ITEM = { + id: V2_POLICY_ID, + country_id: 'us', + tax_benefit_model_id: V2_MODEL_ID, + created_at: '2026-10-20T10:00:00Z', + updated_at: '2026-10-20T10:00:00Z', + parameter_values: [ + { + id: 'b0000000-0000-4000-8000-000000000001', + parameter_id: V2_PARAMETER_IDS.CTC_AMOUNT, + parameter_name: CTC_AMOUNT, + value: 3600, + start_date: '2026-01-01T00:00:00Z', + end_date: '2100-12-31T00:00:00Z', + }, + ], +}; + const fetchMock = vi.fn(); function requestUrl(input: unknown): URL { return new URL(input instanceof Request ? input.url : String(input)); } -/** Answers the account's reform list and the reform's v2 policy. */ -function respond(input: unknown): Response { - if (requestUrl(input).pathname === `/v2/policies/${V2_POLICY_ID}`) { - return okResponse({ - item: { - id: V2_POLICY_ID, - country_id: 'us', - tax_benefit_model_id: V2_MODEL_ID, - created_at: '2026-10-20T10:00:00Z', - updated_at: '2026-10-20T10:00:00Z', - parameter_values: [ - { - id: 'b0000000-0000-4000-8000-000000000001', - parameter_id: V2_PARAMETER_IDS.CTC_AMOUNT, - parameter_name: CTC_AMOUNT, - value: 3600, - start_date: '2026-01-01T00:00:00Z', - end_date: '2100-12-31T00:00:00Z', - }, - ], - }, - }); +function requestMethod(input: unknown, init?: RequestInit): string { + return input instanceof Request ? input.method : (init?.method ?? 'GET'); +} + +/** Answers the account's reform list and saves, and the reform's v2 policy. */ +function respond(input: unknown, init?: RequestInit): Response { + const { pathname } = requestUrl(input); + const method = requestMethod(input, init); + if (method === 'POST' && pathname === '/v2/policies') { + return okResponse({ item: V2_POLICY_ITEM }, 201); + } + if (method === 'POST' && pathname === '/v2/user-policies') { + return itemResponse(V2_REFORM_ROW, 201); + } + if (pathname === `/v2/policies/${V2_POLICY_ID}`) { + return okResponse({ item: V2_POLICY_ITEM }); } return pageResponse([V2_REFORM_ROW]); } +/** The `POST /v2/policies` request a save sent. */ +function policyCreationRequest(): Request | undefined { + return fetchMock.mock.calls + .map(([input]) => input) + .find( + (input): input is Request => + input instanceof Request && + input.method === 'POST' && + requestUrl(input).pathname === '/v2/policies' + ); +} + function v2PolicyRequests(): number { return fetchMock.mock.calls.filter( ([input]) => requestUrl(input).pathname === `/v2/policies/${V2_POLICY_ID}` @@ -134,7 +160,7 @@ function wrapperFor(auth: AuthContextValue, countryId: 'us' | 'ca' = 'us') { beforeEach(() => { fetchMock.mockReset(); - fetchMock.mockImplementation(async (input: unknown) => respond(input)); + fetchMock.mockImplementation(async (input: unknown, init?: RequestInit) => respond(input, init)); vi.stubGlobal('fetch', fetchMock); seedV2Metadata(); }); @@ -166,6 +192,39 @@ describe('useReformStore with v3 on the v2 routes', () => { expect(reforms).toBeInstanceOf(LocalStorageReformStore); }); + test('given v2 metadata then a saved reform makes its policy for the loaded catalog', async () => { + // Given + const reforms = renderHook(() => useReformStore(), { + wrapper: wrapperFor(signedInAuthValue()), + }).result.current; + + // When + const saved = await reforms.create({ + countryId: 'us', + label: 'CTC to $3,600', + parameters: [ + { + name: CTC_AMOUNT, + values: [{ startDate: '2026-01-01', endDate: '2100-12-31', value: 3600 }], + }, + ], + baseline: 'current-law', + provenance: { source: 'manual' }, + }); + + // Then + const policyCreation = policyCreationRequest(); + expect(policyCreation).toBeDefined(); + expect(requestUrl(policyCreation).searchParams.get('policyengine_version')).toBe( + V2_POLICYENGINE_VERSION + ); + expect(await policyCreation!.json()).toMatchObject({ + tax_benefit_model_id: V2_MODEL_ID, + parameter_values: [{ parameter_id: V2_PARAMETER_IDS.CTC_AMOUNT, value: 3600 }], + }); + expect(saved.policyId).toBe(V2_POLICY_ID); + }); + test('given the reform list is loaded again then each v2 policy is fetched only once', async () => { // Given const reforms = renderHook(() => useReformStore(), { diff --git a/app/src/tests/unit/libs/flagship/v3ApiRoutes.test.ts b/app/src/tests/unit/libs/flagship/v3ApiRoutes.test.ts index 886f1e342..02d178b97 100644 --- a/app/src/tests/unit/libs/flagship/v3ApiRoutes.test.ts +++ b/app/src/tests/unit/libs/flagship/v3ApiRoutes.test.ts @@ -8,12 +8,17 @@ describe('v3ApiRoutes', () => { expect(V3_API_ROUTES).toBe('v1'); }); - test('given the v3 workspace route group then metadata follows V3_API_ROUTES', () => { + test('given the v3 workspace route group then metadata follows the v3 routes', () => { + expect(metadataRoutesForRouteGroup('(workspace)', 'v2')).toBe('v2'); + expect(metadataRoutesForRouteGroup('(workspace)', 'v1')).toBe('v1'); + }); + + test('given no v3 routes then the workspace route group follows V3_API_ROUTES', () => { expect(metadataRoutesForRouteGroup('(workspace)')).toBe(V3_API_ROUTES); }); test('given the legacy calculator or no route group then metadata stays on v1', () => { - expect(metadataRoutesForRouteGroup('(calculator)')).toBe('v1'); - expect(metadataRoutesForRouteGroup(null)).toBe('v1'); + expect(metadataRoutesForRouteGroup('(calculator)', 'v2')).toBe('v1'); + expect(metadataRoutesForRouteGroup(null, 'v2')).toBe('v1'); }); }); From 8d7b271a837e6b551f3aa526f39575a1a5dd4353 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:21:09 +0400 Subject: [PATCH 4/5] Require a saved reform row to name its policy in the body type Making legacy_policy_id optional for v2 rows left both IDs optional in UserPolicyCreateBody, so a body naming neither compiled although the API refuses it. The body is now a union that needs policy_id, legacy_policy_id, or both. The 'v1' | 'v2' union was also declared twice (MetadataRoutes and V3ApiRoutes); V3ApiRoutes now reuses MetadataRoutes. The new type test failed `bun run typecheck` first: its @ts-expect-error on a body with neither ID was unused. Co-Authored-By: Claude Opus 5.5 --- app/src/libs/flagship/v3ApiRoutes.ts | 4 +++- app/src/tests/unit/types/v2User.test.ts | 23 +++++++++++++++++++++++ app/src/types/metadata.ts | 6 +++++- app/src/types/v2User.ts | 18 ++++++++++++------ 4 files changed, 43 insertions(+), 8 deletions(-) create mode 100644 app/src/tests/unit/types/v2User.test.ts diff --git a/app/src/libs/flagship/v3ApiRoutes.ts b/app/src/libs/flagship/v3ApiRoutes.ts index 5d6a4b989..ddaf0368a 100644 --- a/app/src/libs/flagship/v3ApiRoutes.ts +++ b/app/src/libs/flagship/v3ApiRoutes.ts @@ -1,3 +1,5 @@ +import type { MetadataRoutes } from '@/types/metadata'; + /** * Which PolicyEngine API routes the v3 workspace uses. * @@ -14,7 +16,7 @@ * (and the test that pins it). The legacy calculator, the `(calculator)` route * group, stays on v1 regardless. */ -export type V3ApiRoutes = 'v1' | 'v2'; +export type V3ApiRoutes = MetadataRoutes; export const V3_API_ROUTES: V3ApiRoutes = 'v1'; diff --git a/app/src/tests/unit/types/v2User.test.ts b/app/src/tests/unit/types/v2User.test.ts new file mode 100644 index 000000000..d6882774c --- /dev/null +++ b/app/src/tests/unit/types/v2User.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'vitest'; +import type { UserPolicyCreateBody } from '@/types/v2User'; + +const V2_POLICY_ID = 'a1b2c3d4-0000-4000-8000-000000000001'; +const V1_POLICY_ID = 95762; + +describe('UserPolicyCreateBody', () => { + // Checked by `bun run typecheck`: each body below compiles only if the + // type requires a row to name a policy, as the API does. + test('given a saved reform row then it names a v2 policy, a v1 policy or both', () => { + // @ts-expect-error A row must name policy_id or legacy_policy_id. + const withoutPolicy: UserPolicyCreateBody = { country_id: 'us' }; + const onV2Policy: UserPolicyCreateBody = { country_id: 'us', policy_id: V2_POLICY_ID }; + const onV1Policy: UserPolicyCreateBody = { country_id: 'us', legacy_policy_id: V1_POLICY_ID }; + const onLinkedPolicies: UserPolicyCreateBody = { + country_id: 'us', + policy_id: V2_POLICY_ID, + legacy_policy_id: V1_POLICY_ID, + }; + + expect([withoutPolicy, onV2Policy, onV1Policy, onLinkedPolicies]).toHaveLength(4); + }); +}); diff --git a/app/src/types/metadata.ts b/app/src/types/metadata.ts index 4b4ad0ede..58871053f 100644 --- a/app/src/types/metadata.ts +++ b/app/src/types/metadata.ts @@ -31,7 +31,11 @@ export interface V2CatalogReference { parameterIds: Record; } -/** Which API routes model metadata was loaded from. */ +/** + * Which API routes model metadata was loaded from: v1's + * `GET /{country}/metadata` or the v2 catalog routes. v3's choice between + * them (V3ApiRoutes in libs/flagship/v3ApiRoutes) is the same type. + */ export type MetadataRoutes = 'v1' | 'v2'; export interface MetadataApiPayload { diff --git a/app/src/types/v2User.ts b/app/src/types/v2User.ts index 97d432c04..493485163 100644 --- a/app/src/types/v2User.ts +++ b/app/src/types/v2User.ts @@ -136,17 +136,23 @@ export interface SharedReportItem { /* Create and patch bodies (strict on the server: unknown keys are a 400). */ -export interface UserPolicyCreateBody { +/** + * The policy a saved row points at. The API takes a v2 policy + * (`policy_id`, from `/v2/policies`), a v1 policy (`legacy_policy_id`, + * from `POST /{country}/policy`), or both when they name the same policy; + * a row with neither is refused. + */ +export type UserPolicyReference = + | { policy_id: string; legacy_policy_id?: number } + | { policy_id?: string; legacy_policy_id: number }; + +export type UserPolicyCreateBody = UserPolicyReference & { country_id: AssociationCountryId; - /** A v2 policy (`/v2/policies`); a row needs this or legacy_policy_id. */ - policy_id?: string; - /** A v1 policy (`POST /{country}/policy`). */ - legacy_policy_id?: number; name?: string | null; description?: string | null; baseline?: BaselineWire | null; provenance?: ProvenanceWire | null; -} +}; export type UserPolicyPatchBody = Partial< Pick< From 99be8faf3d45e5b66c7b772a9d8da20dc6adf0ac Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:21:53 +0400 Subject: [PATCH 5/5] Describe both reform stores, and name the policy cache for both - The reform store module's header still said every save makes a v1 policy. It now describes the browser store, the account store on v1 policies before the switch, and the account store on /v2/policies after it. - V1_POLICY_JSON_GC_TIME also sets how long cached v2 policies are kept, so it is now SAVED_POLICY_GC_TIME. - Comments in v3ApiRoutes and the v2 metadata adapter call policyengine-api the Public API. Documentation and naming only, so no test could fail first. Co-Authored-By: Claude Opus 5.5 --- app/src/api/publicApi/metadata.ts | 2 +- app/src/api/reformStore.ts | 16 ++++++++++------ app/src/hooks/useFlagshipStores.ts | 8 ++++---- app/src/libs/flagship/v3ApiRoutes.ts | 2 +- 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/app/src/api/publicApi/metadata.ts b/app/src/api/publicApi/metadata.ts index 87e91dbc5..0f842c5fc 100644 --- a/app/src/api/publicApi/metadata.ts +++ b/app/src/api/publicApi/metadata.ts @@ -5,7 +5,7 @@ import type { ParameterMetadataCollection } from '@/types/metadata/parameterMeta import type { ValuesList } from '@/types/subIngredients/valueInterval'; /** - * The v3 workspace's model metadata from the PolicyEngine API's v2 routes, + * The v3 workspace's model metadata from the Public API's v2 routes, * in the shape `GET /{country}/metadata` returns, so everything that reads * the metadata store works unchanged: * diff --git a/app/src/api/reformStore.ts b/app/src/api/reformStore.ts index 471f6ad32..cbc16ad1b 100644 --- a/app/src/api/reformStore.ts +++ b/app/src/api/reformStore.ts @@ -30,12 +30,16 @@ import { coercePolicyParameterValue, resolvePolicyParameterValueSpec } from '@/u /** * Store for the flagship app's reform objects. * - * Either way, saving a reform materializes its parameters as a v1 policy - * (`POST /{country}/policy`, which dedupes identical ones), and a save - * that cannot create the policy fails. Signed out (and outside the US and - * UK) the reform, with its policy ID, lives in localStorage. Signed in, it - * is a row in the account's `/v2/user-policies` carrying the policy ID, - * the name, the baseline, and the provenance. + * Saving a reform first makes its parameters a policy, and a save that + * cannot create the policy fails. Where saved work is in the browser + * (sign-in not configured, or outside the US and UK) the reform, with the + * ID of its v1 policy (`POST /{country}/policy`), lives in localStorage. + * Where it is in the account, signed in or signed out, the reform is a row + * in `/v2/user-policies` carrying the name, the baseline, the provenance + * and the policy: a v1 policy (`legacy_policy_id`, ApiReformStore) before + * the switch, and a `/v2/policies` policy (`policy_id`, ApiV2ReformStore) + * after it. V3_API_ROUTES picks the account store. Both APIs deduplicate + * identical policies. */ export type NewReform = Omit; export type ReformUpdates = Partial>; diff --git a/app/src/hooks/useFlagshipStores.ts b/app/src/hooks/useFlagshipStores.ts index 785e79be6..f09d7e2df 100644 --- a/app/src/hooks/useFlagshipStores.ts +++ b/app/src/hooks/useFlagshipStores.ts @@ -74,8 +74,8 @@ const v1PolicyJsonKey = (countryId: string, policyId: string) => const v2PolicyKey = (countryId: string, policyId: string) => ['v2-policy', countryId, policyId] as const; -/** How long a fetched v1 policy stays cached without a reader. */ -const V1_POLICY_JSON_GC_TIME = 30 * 60 * 1000; +/** How long a fetched policy (v1 or v2) stays cached without a reader. */ +const SAVED_POLICY_GC_TIME = 30 * 60 * 1000; /** * The reform store for this session. Which policies it makes (v1 or v2) @@ -104,14 +104,14 @@ export function useReformStore(): ReformStore { queryKey: v1PolicyJsonKey(countryId, policyId), queryFn: () => fetchV1PolicyJson(countryId, policyId), staleTime: Infinity, - gcTime: V1_POLICY_JSON_GC_TIME, + gcTime: SAVED_POLICY_GC_TIME, }), fetchV2Policy: (countryId, policyId) => queryClient.fetchQuery({ queryKey: v2PolicyKey(countryId, policyId), queryFn: () => fetchV2PolicyItem(countryId, policyId), staleTime: Infinity, - gcTime: V1_POLICY_JSON_GC_TIME, + gcTime: SAVED_POLICY_GC_TIME, }), }), [account, queryClient] diff --git a/app/src/libs/flagship/v3ApiRoutes.ts b/app/src/libs/flagship/v3ApiRoutes.ts index ddaf0368a..3f2ec06c7 100644 --- a/app/src/libs/flagship/v3ApiRoutes.ts +++ b/app/src/libs/flagship/v3ApiRoutes.ts @@ -1,7 +1,7 @@ import type { MetadataRoutes } from '@/types/metadata'; /** - * Which PolicyEngine API routes the v3 workspace uses. + * Which Public API routes (policyengine-api) the v3 workspace uses. * * - 'v1': `GET /{country}/metadata` for the model, and saved reforms as * `/v2/user-policies` rows pointing at v1 policies (`POST /{country}/policy`,