diff --git a/app/src/api/publicApi/metadata.ts b/app/src/api/publicApi/metadata.ts new file mode 100644 index 000000000..0f842c5fc --- /dev/null +++ b/app/src/api/publicApi/metadata.ts @@ -0,0 +1,274 @@ +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 Public 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('_', ' '); +} + +/** 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 +): 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: v1Values(parameter.values), + 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..cbc16ad1b 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,16 +25,21 @@ import type { UserPolicyItem, UserPolicyPatchBody, } from '@/types/v2User'; +import { coercePolicyParameterValue, resolvePolicyParameterValueSpec } from '@/utils/valueCoercion'; /** * 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>; @@ -403,10 +413,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..f09d7e2df 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,29 +70,48 @@ export function useV3SavedWork(): V3SavedWork { const v1PolicyJsonKey = (countryId: string, policyId: string) => ['v1-policy-json', countryId, policyId] as const; -/** How long a fetched v1 policy stays cached without a reader. */ -const V1_POLICY_JSON_GC_TIME = 30 * 60 * 1000; +/** 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 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) + * 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({ 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: SAVED_POLICY_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..3f2ec06c7 --- /dev/null +++ b/app/src/libs/flagship/v3ApiRoutes.ts @@ -0,0 +1,36 @@ +import type { MetadataRoutes } from '@/types/metadata'; + +/** + * 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`, + * `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 = MetadataRoutes; + +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 `v3Routes` (V3_API_ROUTES unless + * a test names the routes after the switch); everything else stays on 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/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..d2641802b --- /dev/null +++ b/app/src/tests/unit/api/publicApi/metadata.test.ts @@ -0,0 +1,312 @@ +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 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(); + + // 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/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/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..1a511201e --- /dev/null +++ b/app/src/tests/unit/hooks/useFlagshipStoresV2.test.tsx @@ -0,0 +1,248 @@ +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, + itemResponse, + 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 }, +}); + +/** 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)); +} + +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}` + ).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, init?: RequestInit) => respond(input, init)); + 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 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(), { + 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..02d178b97 --- /dev/null +++ b/app/src/tests/unit/libs/flagship/v3ApiRoutes.test.ts @@ -0,0 +1,24 @@ +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 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)', 'v2')).toBe('v1'); + expect(metadataRoutesForRouteGroup(null, 'v2')).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/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 c4476d78f..58871053f 100644 --- a/app/src/types/metadata.ts +++ b/app/src/types/metadata.ts @@ -19,6 +19,25 @@ 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: 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 { status: string; message: string | null; @@ -40,6 +59,8 @@ export interface MetadataApiPayload { }; version: string; spm?: SPMMetadata; + /** Set only when the metadata came from the v2 routes. */ + v2Catalog?: V2CatalogReference; }; } @@ -85,6 +106,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..493485163 100644 --- a/app/src/types/v2User.ts +++ b/app/src/types/v2User.ts @@ -136,19 +136,28 @@ 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; - legacy_policy_id: number; name?: string | null; description?: string | null; baseline?: BaselineWire | null; provenance?: ProvenanceWire | null; -} +}; 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}; }