diff --git a/app/src/adapters/PolicyAdapter.ts b/app/src/adapters/PolicyAdapter.ts index 951274da2..dc1ac8a89 100644 --- a/app/src/adapters/PolicyAdapter.ts +++ b/app/src/adapters/PolicyAdapter.ts @@ -1,32 +1,110 @@ +import { V2PolicyCreatePayload, V2PolicyParameterValue, V2PolicyResponse } from '@/api/policy'; import { Policy } from '@/types/ingredients/Policy'; -import { PolicyMetadata } from '@/types/metadata/policyMetadata'; -import { PolicyCreationPayload } from '@/types/payloads'; -import { convertParametersToPolicyJson, convertPolicyJsonToParameters } from './conversionHelpers'; +import { ParameterMetadata } from '@/types/metadata'; /** * Adapter for converting between Policy and API formats */ export class PolicyAdapter { /** - * Converts PolicyMetadata from API GET response to Policy type - * Handles snake_case to camelCase conversion + * Converts V2 API response to Policy type */ - static fromMetadata(metadata: PolicyMetadata): Policy { + static fromV2Response(response: V2PolicyResponse): Policy { return { - id: String(metadata.id), - countryId: metadata.country_id, - apiVersion: metadata.api_version, - parameters: convertPolicyJsonToParameters(metadata.policy_json), + id: response.id, + taxBenefitModelId: response.tax_benefit_model_id, + // Note: V2 response doesn't include parameter values directly + // They would need to be fetched separately if needed + parameters: [], }; } /** - * Converts Policy to format for API POST request - * Note: API expects snake_case, but we handle that at the API layer + * Converts Policy to V2 API creation payload + * + * @param policy - Policy with parameters (names and values) + * @param parametersMetadata - Metadata record for name→ID lookup + * @param taxBenefitModelId - UUID of the tax benefit model + * @param name - Optional policy name (defaults to "Unnamed policy") + * @param description - Optional policy description */ - static toCreationPayload(policy: Policy): PolicyCreationPayload { + static toV2CreationPayload( + policy: Policy, + parametersMetadata: Record, + taxBenefitModelId: string, + name?: string, + description?: string + ): V2PolicyCreatePayload { + const parameterValues: V2PolicyParameterValue[] = []; + + for (const param of policy.parameters || []) { + const parameterId = PolicyAdapter.getParameterIdByName(param.name, parametersMetadata); + + if (!parameterId) { + console.warn(`Parameter ID not found for: ${param.name}`); + continue; + } + + // Convert each value interval to a V2 parameter value + for (const interval of param.values) { + const startDate = PolicyAdapter.toISOTimestamp(interval.startDate); + // Skip if start_date would be null (shouldn't happen in practice) + if (!startDate) { + console.warn(`Invalid start date for parameter: ${param.name}`); + continue; + } + + parameterValues.push({ + parameter_id: parameterId, + value_json: interval.value, + start_date: startDate, + end_date: PolicyAdapter.toISOTimestamp(interval.endDate), + }); + } + } + return { - data: convertParametersToPolicyJson(policy.parameters || []), + name: name || 'Unnamed policy', + description, + tax_benefit_model_id: taxBenefitModelId, + parameter_values: parameterValues, }; } + + /** + * Look up parameter ID by name from metadata + */ + private static getParameterIdByName( + paramName: string, + parametersMetadata: Record + ): string | null { + // First try direct lookup by parameter path + const param = parametersMetadata[paramName]; + if (param?.id) { + return param.id; + } + + // Also check by the 'parameter' field which might be the path + for (const metadata of Object.values(parametersMetadata)) { + if (metadata.parameter === paramName && metadata.id) { + return metadata.id; + } + } + + return null; + } + + /** + * Convert date string (YYYY-MM-DD) to ISO timestamp (YYYY-MM-DDTHH:MM:SSZ) + * Returns null for "forever" dates (9999-12-31 or 2100-12-31) + */ + private static toISOTimestamp(dateStr: string): string | null { + // Treat far-future dates as "indefinite" (null in v2 API) + if (dateStr === '9999-12-31' || dateStr === '2100-12-31') { + return null; + } + + // Convert YYYY-MM-DD to ISO timestamp at midnight UTC + return `${dateStr}T00:00:00Z`; + } } diff --git a/app/src/adapters/UserPolicyAdapter.ts b/app/src/adapters/UserPolicyAdapter.ts deleted file mode 100644 index ea9024fea..000000000 --- a/app/src/adapters/UserPolicyAdapter.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { UserPolicy } from '@/types/ingredients/UserPolicy'; -import { - UserPolicyCreationMetadata, - UserPolicyMetadata, -} from '@/types/metadata/userPolicyMetadata'; - -/** - * Adapter for converting between UserPolicy and API formats - */ -export class UserPolicyAdapter { - /** - * Convert UserPolicy to API creation payload - * Handles camelCase to snake_case conversion - * Note: API endpoint doesn't exist yet - */ - static toCreationPayload( - userPolicy: Omit - ): UserPolicyCreationMetadata { - return { - user_id: String(userPolicy.userId), - policy_id: String(userPolicy.policyId), - country_id: userPolicy.countryId, - label: userPolicy.label, - updated_at: userPolicy.updatedAt || new Date().toISOString(), - }; - } - - /** - * Convert API response to UserPolicy - * Handles snake_case to camelCase conversion - * Explicitly coerces IDs to strings to handle JSON.parse type mismatches - * Note: API endpoint doesn't exist yet - */ - static fromApiResponse(apiData: UserPolicyMetadata): UserPolicy { - return { - id: String(apiData.policy_id), - userId: String(apiData.user_id), - policyId: String(apiData.policy_id), - countryId: apiData.country_id, - label: apiData.label ?? undefined, - createdAt: apiData.created_at, - updatedAt: apiData.updated_at, - isCreated: true, - }; - } -} diff --git a/app/src/adapters/index.ts b/app/src/adapters/index.ts index 203c07c14..b6c1554cc 100644 --- a/app/src/adapters/index.ts +++ b/app/src/adapters/index.ts @@ -8,7 +8,6 @@ export type { DatasetEntry } from './MetadataAdapter'; // User Ingredient Adapters export { UserReportAdapter } from './UserReportAdapter'; -export { UserPolicyAdapter } from './UserPolicyAdapter'; export { UserSimulationAdapter } from './UserSimulationAdapter'; export { UserHouseholdAdapter } from './UserHouseholdAdapter'; export { UserGeographicAdapter } from './UserGeographicAdapter'; diff --git a/app/src/api/policy.ts b/app/src/api/policy.ts index 70d03d711..9504ccef0 100644 --- a/app/src/api/policy.ts +++ b/app/src/api/policy.ts @@ -1,9 +1,42 @@ -import { BASE_URL } from '@/constants'; -import { PolicyMetadata } from '@/types/metadata/policyMetadata'; -import { PolicyCreationPayload } from '@/types/payloads'; +import { API_V2_BASE_URL } from '@/api/v2/taxBenefitModels'; -export async function fetchPolicyById(country: string, policyId: string): Promise { - const url = `${BASE_URL}/${country}/policy/${policyId}`; +/** + * V2 Policy parameter value - represents a single parameter change + */ +export interface V2PolicyParameterValue { + parameter_id: string; // UUID of the parameter + value_json: number | string | boolean | Record; + start_date: string; // ISO timestamp (e.g., "2025-01-01T00:00:00Z") + end_date: string | null; // ISO timestamp or null for indefinite +} + +/** + * V2 Policy creation payload + */ +export interface V2PolicyCreatePayload { + name: string; + description?: string; + tax_benefit_model_id: string; // UUID of the tax benefit model + parameter_values: V2PolicyParameterValue[]; +} + +/** + * V2 Policy response from API + */ +export interface V2PolicyResponse { + id: string; + name: string; + description: string | null; + tax_benefit_model_id: string; + created_at: string; + updated_at: string; +} + +/** + * Fetch a policy by ID from v2 API + */ +export async function fetchPolicyById(policyId: string): Promise { + const url = `${API_V2_BASE_URL}/policies/${policyId}`; const res = await fetch(url, { method: 'GET', @@ -17,25 +50,42 @@ export async function fetchPolicyById(country: string, policyId: string): Promis throw new Error(`Failed to fetch policy ${policyId}`); } - const json = await res.json(); - - return json.result; + return res.json(); } -export async function createPolicy( - countryId: string, - data: PolicyCreationPayload -): Promise<{ result: { policy_id: string } }> { - const url = `${BASE_URL}/${countryId}/policy`; +/** + * Create a new policy via v2 API + */ +export async function createPolicy(payload: V2PolicyCreatePayload): Promise { + const url = `${API_V2_BASE_URL}/policies/`; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), + body: JSON.stringify(payload), }); if (!res.ok) { - throw new Error('Failed to create policy'); + const errorText = await res.text(); + throw new Error(`Failed to create policy: ${res.status} ${errorText}`); + } + + return res.json(); +} + +/** + * List all policies, optionally filtered by tax benefit model + */ +export async function listPolicies(taxBenefitModelId?: string): Promise { + let url = `${API_V2_BASE_URL}/policies/`; + if (taxBenefitModelId) { + url += `?tax_benefit_model_id=${taxBenefitModelId}`; + } + + const res = await fetch(url); + + if (!res.ok) { + throw new Error('Failed to list policies'); } return res.json(); diff --git a/app/src/api/policyAssociation.ts b/app/src/api/policyAssociation.ts index dc073aac0..af854b568 100644 --- a/app/src/api/policyAssociation.ts +++ b/app/src/api/policyAssociation.ts @@ -1,97 +1,46 @@ -import { UserPolicyAdapter } from '@/adapters/UserPolicyAdapter'; -import { UserPolicyCreationPayload } from '@/types/payloads'; import { UserPolicy } from '../types/ingredients/UserPolicy'; +import { + createUserPolicyAssociationV2, + deleteUserPolicyAssociationV2, + fetchUserPolicyAssociationByIdV2, + fetchUserPolicyAssociationsV2, + updateUserPolicyAssociationV2, +} from './v2/userPolicyAssociations'; export interface UserPolicyStore { create: (policy: Omit) => Promise; findByUser: (userId: string, countryId?: string) => Promise; - findById: (userId: string, policyId: string) => Promise; - update: (userPolicyId: string, updates: Partial) => Promise; - // The below are not yet implemented, but keeping for future use - // delete(userPolicyId: string): Promise; + findById: (userPolicyId: string) => Promise; + update: (userPolicyId: string, updates: Partial, userId: string) => Promise; + delete: (userPolicyId: string, userId: string) => Promise; } export class ApiPolicyStore implements UserPolicyStore { - // TODO: Modify value to match to-be-created API endpoint structure - private readonly BASE_URL = '/api/user-policy-associations'; - async create(policy: Omit): Promise { - const payload: UserPolicyCreationPayload = UserPolicyAdapter.toCreationPayload(policy); - - const response = await fetch(`${this.BASE_URL}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - throw new Error('Failed to create policy association'); - } - - const apiResponse = await response.json(); - return UserPolicyAdapter.fromApiResponse(apiResponse); + return createUserPolicyAssociationV2(policy); } async findByUser(userId: string, countryId?: string): Promise { - const response = await fetch(`${this.BASE_URL}/user/${userId}`, { - headers: { 'Content-Type': 'application/json' }, - }); - if (!response.ok) { - throw new Error('Failed to fetch user associations'); - } - - const apiResponses = await response.json(); - - // Convert each API response to UserPolicy and filter by country if specified - const policies = apiResponses.map((apiData: any) => UserPolicyAdapter.fromApiResponse(apiData)); - return countryId ? policies.filter((p: UserPolicy) => p.countryId === countryId) : policies; - } - - async findById(userId: string, policyId: string): Promise { - const response = await fetch(`${this.BASE_URL}/${userId}/${policyId}`, { - headers: { 'Content-Type': 'application/json' }, - }); - - if (response.status === 404) { - return null; - } - - if (!response.ok) { - throw new Error('Failed to fetch association'); - } - - const apiData = await response.json(); - return UserPolicyAdapter.fromApiResponse(apiData); + return fetchUserPolicyAssociationsV2(userId, countryId); } - async update(_userPolicyId: string, _updates: Partial): Promise { - // TODO: Implement when backend API endpoint is available - // Expected endpoint: PUT /api/user-policy-associations/:userPolicyId - // Expected payload: UserPolicyUpdatePayload (to be created) - - console.warn( - '[ApiPolicyStore.update] API endpoint not yet implemented. ' + - 'This method will be activated when user authentication is added.' - ); - - throw new Error( - 'Policy updates via API are not yet supported. ' + - 'Please ensure you are using localStorage mode.' - ); + async findById(userPolicyId: string): Promise { + return fetchUserPolicyAssociationByIdV2(userPolicyId); } - // Not yet implemented, but keeping for future use - /* - async delete(userId: string, policyId: string): Promise { - const response = await fetch(`/api/user-policy-associations/${userId}/${policyId}`, { - method: 'DELETE', + async update( + userPolicyId: string, + updates: Partial, + userId: string + ): Promise { + return updateUserPolicyAssociationV2(userPolicyId, userId, { + label: updates.label ?? null, }); + } - if (!response.ok) { - throw new Error('Failed to delete association'); - } + async delete(userPolicyId: string, userId: string): Promise { + return deleteUserPolicyAssociationV2(userPolicyId, userId); } - */ } export class LocalStoragePolicyStore implements UserPolicyStore { @@ -122,12 +71,14 @@ export class LocalStoragePolicyStore implements UserPolicyStore { async findByUser(userId: string, countryId?: string): Promise { const policies = this.getStoredPolicies(); - return policies.filter((p) => p.userId === userId && (!countryId || p.countryId === countryId)); + return policies.filter( + (p) => p.userId === userId && (!countryId || p.countryId === countryId) + ); } - async findById(userId: string, policyId: string): Promise { + async findById(userPolicyId: string): Promise { const policies = this.getStoredPolicies(); - return policies.find((p) => p.userId === userId && p.policyId === policyId) || null; + return policies.find((p) => p.id === userPolicyId) || null; } private getStoredPolicies(): UserPolicy[] { @@ -159,7 +110,11 @@ export class LocalStoragePolicyStore implements UserPolicyStore { localStorage.setItem(this.STORAGE_KEY, JSON.stringify(policies)); } - async update(userPolicyId: string, updates: Partial): Promise { + async update( + userPolicyId: string, + updates: Partial, + _userId: string + ): Promise { const policies = this.getStoredPolicies(); // Find by userPolicy.id (the "sup-" prefixed ID), NOT policyId @@ -182,14 +137,15 @@ export class LocalStoragePolicyStore implements UserPolicyStore { return updated; } - // Not yet implemented, but keeping for future use - /* - async delete(userId: string, policyId: string): Promise { + async delete(userPolicyId: string, _userId: string): Promise { const policies = this.getStoredPolicies(); - const filtered = policies.filter( - a => !(a.userId === userId && a.policyId === policyId) - ); + const index = policies.findIndex((p) => p.id === userPolicyId); + + if (index === -1) { + throw new Error(`UserPolicy with id ${userPolicyId} not found`); + } + + const filtered = policies.filter((p) => p.id !== userPolicyId); this.setStoredPolicies(filtered); } - */ } diff --git a/app/src/api/v2/taxBenefitModels.ts b/app/src/api/v2/taxBenefitModels.ts index 562802580..c84c18418 100644 --- a/app/src/api/v2/taxBenefitModels.ts +++ b/app/src/api/v2/taxBenefitModels.ts @@ -1,4 +1,5 @@ -export const API_V2_BASE_URL = 'https://v2.api.policyengine.org'; +export const API_V2_BASE_URL = + import.meta.env.VITE_API_V2_URL || 'https://v2.api.policyengine.org'; /** * Map country IDs to their API model names. diff --git a/app/src/api/v2/userPolicyAssociations.ts b/app/src/api/v2/userPolicyAssociations.ts new file mode 100644 index 000000000..45ca1f771 --- /dev/null +++ b/app/src/api/v2/userPolicyAssociations.ts @@ -0,0 +1,232 @@ +/** + * User Policy Associations API - v2 Alpha + * + * Handles CRUD operations for user-policy associations via API v2 alpha. + * These associations link users to their saved policies. + * + * API Endpoints (from policyengine-api-v2-alpha): + * - POST /user-policies/ - Create association + * - GET /user-policies/?user_id=...&country_id=.. - List by user (optional country_id filter) + * - GET /user-policies/{user_policy_id} - Get by ID + * - PATCH /user-policies/{user_policy_id}?user_id=. - Update association (ownership verified) + * - DELETE /user-policies/{user_policy_id}?user_id=. - Delete association (ownership verified) + */ + +import { CountryId } from '@/libs/countries'; +import { UserPolicy } from '@/types/ingredients/UserPolicy'; + +import { API_V2_BASE_URL } from './taxBenefitModels'; + +// ============================================================================ +// Types for v2 Alpha API +// ============================================================================ + +/** + * API response format (snake_case) - matches backend UserPolicyRead + */ +export interface UserPolicyAssociationV2Response { + id: string; + user_id: string; + policy_id: string; + country_id: string; + label: string | null; + created_at: string; + updated_at: string; +} + +/** + * API request format for creating associations - matches backend UserPolicyCreate + */ +export interface UserPolicyAssociationV2CreateRequest { + user_id: string; + policy_id: string; + country_id: string; + label?: string | null; +} + +/** + * API request format for updating associations - matches backend UserPolicyUpdate + */ +export interface UserPolicyAssociationV2UpdateRequest { + label?: string | null; +} + +// ============================================================================ +// Conversion Functions +// ============================================================================ + +/** + * Convert app format to v2 API create request + */ +export function toV2CreateRequest( + userPolicy: Omit +): UserPolicyAssociationV2CreateRequest { + return { + user_id: userPolicy.userId, + policy_id: userPolicy.policyId, + country_id: userPolicy.countryId, + label: userPolicy.label ?? null, + }; +} + +/** + * Convert v2 API response to app format + */ +export function fromV2Response(response: UserPolicyAssociationV2Response): UserPolicy { + return { + id: response.id, + userId: response.user_id, + policyId: response.policy_id, + countryId: response.country_id as CountryId, + label: response.label ?? undefined, + createdAt: response.created_at, + updatedAt: response.updated_at ?? undefined, + isCreated: true, + }; +} + +// ============================================================================ +// API Functions +// ============================================================================ + +const BASE_PATH = '/user-policies'; + +/** + * Create a new user-policy association + * POST /user-policies/ + */ +export async function createUserPolicyAssociationV2( + userPolicy: Omit +): Promise { + const url = `${API_V2_BASE_URL}${BASE_PATH}/`; + const body = toV2CreateRequest(userPolicy); + + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to create policy association: ${res.status} ${errorText}`); + } + + const json: UserPolicyAssociationV2Response = await res.json(); + return fromV2Response(json); +} + +/** + * Fetch associations by user ID, optionally filtered by country + * GET /user-policies/?user_id=...&country_id=... + */ +export async function fetchUserPolicyAssociationsV2( + userId: string, + countryId?: string +): Promise { + const params = new URLSearchParams({ user_id: userId }); + if (countryId) { + params.append('country_id', countryId); + } + + const url = `${API_V2_BASE_URL}${BASE_PATH}/?${params}`; + + const res = await fetch(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to fetch user policy associations: ${res.status} ${errorText}`); + } + + const json: UserPolicyAssociationV2Response[] = await res.json(); + return json.map(fromV2Response); +} + +/** + * Fetch a single association by its ID + * GET /user-policies/{user_policy_id} + */ +export async function fetchUserPolicyAssociationByIdV2( + userPolicyId: string +): Promise { + const url = `${API_V2_BASE_URL}${BASE_PATH}/${userPolicyId}`; + + const res = await fetch(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (res.status === 404) { + return null; + } + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to fetch policy association: ${res.status} ${errorText}`); + } + + const json: UserPolicyAssociationV2Response = await res.json(); + return fromV2Response(json); +} + +/** + * Update an existing association + * PATCH /user-policies/{user_policy_id}?user_id=... + * + * Backend requires user_id as query param for ownership verification. + */ +export async function updateUserPolicyAssociationV2( + userPolicyId: string, + userId: string, + updates: UserPolicyAssociationV2UpdateRequest +): Promise { + const params = new URLSearchParams({ user_id: userId }); + const url = `${API_V2_BASE_URL}${BASE_PATH}/${userPolicyId}?${params}`; + + const res = await fetch(url, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(updates), + }); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to update policy association: ${res.status} ${errorText}`); + } + + const json: UserPolicyAssociationV2Response = await res.json(); + return fromV2Response(json); +} + +/** + * Delete an association + * DELETE /user-policies/{user_policy_id}?user_id=... + * + * Backend requires user_id as query param for ownership verification. + */ +export async function deleteUserPolicyAssociationV2( + userPolicyId: string, + userId: string +): Promise { + const params = new URLSearchParams({ user_id: userId }); + const url = `${API_V2_BASE_URL}${BASE_PATH}/${userPolicyId}?${params}`; + + const res = await fetch(url, { + method: 'DELETE', + }); + + // API returns 204 on success, 404 if not found (both are acceptable for delete) + if (!res.ok && res.status !== 404) { + const errorText = await res.text(); + throw new Error(`Failed to delete policy association: ${res.status} ${errorText}`); + } +} diff --git a/app/src/hooks/useCreatePolicy.ts b/app/src/hooks/useCreatePolicy.ts index bedcd682e..fd6401dc4 100644 --- a/app/src/hooks/useCreatePolicy.ts +++ b/app/src/hooks/useCreatePolicy.ts @@ -1,29 +1,55 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { createPolicy } from '@/api/policy'; -import { MOCK_USER_ID } from '@/constants'; +import { useSelector } from 'react-redux'; +import { PolicyAdapter } from '@/adapters'; +import { createPolicy, V2PolicyCreatePayload } from '@/api/policy'; import { policyKeys } from '@/libs/queryKeys'; -import { PolicyCreationPayload } from '@/types/payloads'; +import { RootState } from '@/store'; +import { Policy } from '@/types/ingredients/Policy'; import { useCurrentCountry } from './useCurrentCountry'; +import { useTaxBenefitModelId } from './useTaxBenefitModel'; +import { useUserId } from './useUserId'; import { useCreatePolicyAssociation } from './useUserPolicy'; +interface CreatePolicyInput { + policy: Policy; + name?: string; + description?: string; +} + export function useCreatePolicy(policyLabel?: string) { const queryClient = useQueryClient(); const countryId = useCurrentCountry(); - // const user = MOCK_USER_ID; // TODO: Replace with actual user context or auth hook in future + const { taxBenefitModelId, isLoading: isModelLoading } = useTaxBenefitModelId(countryId); + const parametersMetadata = useSelector((state: RootState) => state.metadata.parameters); const createAssociation = useCreatePolicyAssociation(); + const userId = useUserId(); const mutation = useMutation({ - mutationFn: (data: PolicyCreationPayload) => createPolicy(countryId, data), + mutationFn: async (input: CreatePolicyInput): Promise<{ id: string }> => { + if (!taxBenefitModelId) { + throw new Error('Tax benefit model ID not available'); + } + + // Convert policy to v2 payload using adapter + const payload: V2PolicyCreatePayload = PolicyAdapter.toV2CreationPayload( + input.policy, + parametersMetadata, + taxBenefitModelId, + input.name || policyLabel, + input.description + ); + + const response = await createPolicy(payload); + return { id: response.id }; + }, onSuccess: async (data) => { try { queryClient.invalidateQueries({ queryKey: policyKeys.all }); - // Create association with current user (or anonymous for session storage) - const userId = MOCK_USER_ID; // TODO: Replace with actual user ID retrieval logic and add conditional logic to access user ID - + // Create association with current user (localStorage-persisted UUID) await createAssociation.mutateAsync({ userId, - policyId: data.result.policy_id, // This is from the API response structure; may be modified in API v2 + policyId: data.id, countryId, label: policyLabel, isCreated: true, @@ -36,7 +62,8 @@ export function useCreatePolicy(policyLabel?: string) { return { createPolicy: mutation.mutateAsync, - isPending: mutation.isPending, + isPending: mutation.isPending || isModelLoading, error: mutation.error, + isModelReady: !!taxBenefitModelId, }; } diff --git a/app/src/hooks/usePolicy.ts b/app/src/hooks/usePolicy.ts index 4243194f1..24efd0d4a 100644 --- a/app/src/hooks/usePolicy.ts +++ b/app/src/hooks/usePolicy.ts @@ -10,6 +10,6 @@ export function usePolicy(country?: string, policyId = '88713') { // hardcoded a default value until user policies integrated return useQuery({ queryKey: ['policy', resolvedCountry, policyId], - queryFn: () => fetchPolicyById(resolvedCountry, policyId), + queryFn: () => fetchPolicyById(policyId), }); } diff --git a/app/src/hooks/useSaveSharedReport.ts b/app/src/hooks/useSaveSharedReport.ts index 8557637f3..cc13210ed 100644 --- a/app/src/hooks/useSaveSharedReport.ts +++ b/app/src/hooks/useSaveSharedReport.ts @@ -84,7 +84,6 @@ export function useSaveSharedReport() { createPolicyAssociation.mutateAsync({ userId, policyId: policy.policyId, - countryId: policy.countryId as CountryId, label: policy.label ?? undefined, }) ); diff --git a/app/src/hooks/useTaxBenefitModel.ts b/app/src/hooks/useTaxBenefitModel.ts new file mode 100644 index 000000000..c7187683a --- /dev/null +++ b/app/src/hooks/useTaxBenefitModel.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchTaxBenefitModels, getModelName, TaxBenefitModel } from '@/api/v2/taxBenefitModels'; + +/** + * Hook to get the TaxBenefitModel ID for a country + */ +export function useTaxBenefitModelId(countryId: string) { + const modelName = getModelName(countryId); + + const query = useQuery({ + queryKey: ['taxBenefitModels'], + queryFn: fetchTaxBenefitModels, + staleTime: 1000 * 60 * 60, // 1 hour - models don't change often + select: (models: TaxBenefitModel[]) => { + const model = models.find((m) => m.name === modelName); + return model?.id ?? null; + }, + }); + + return { + taxBenefitModelId: query.data ?? null, + isLoading: query.isLoading, + error: query.error, + }; +} diff --git a/app/src/hooks/useUserId.ts b/app/src/hooks/useUserId.ts new file mode 100644 index 000000000..2e222eb47 --- /dev/null +++ b/app/src/hooks/useUserId.ts @@ -0,0 +1,24 @@ +import { useMemo } from 'react'; + +import { getUserId } from '@/libs/userIdentity'; + +/** + * React hook that provides the current user's persistent ID. + * + * The ID is stable across renders and sessions - it's stored in localStorage + * and only generated once per browser. + * + * @returns The user's unique identifier + * + * @example + * ```tsx + * function MyComponent() { + * const userId = useUserId(); + * const { data } = useUserHouseholds(userId); + * // ... + * } + * ``` + */ +export function useUserId(): string { + return useMemo(() => getUserId(), []); +} diff --git a/app/src/hooks/useUserPolicy.ts b/app/src/hooks/useUserPolicy.ts index 841b2ebf9..49b081ed6 100644 --- a/app/src/hooks/useUserPolicy.ts +++ b/app/src/hooks/useUserPolicy.ts @@ -1,8 +1,8 @@ -// Import auth hook here in future; for now, mocked out below import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; import { PolicyAdapter } from '@/adapters'; import { fetchPolicyById } from '@/api/policy'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useUserId } from '@/hooks/useUserId'; import { Policy } from '@/types/ingredients/Policy'; import { ApiPolicyStore, LocalStoragePolicyStore } from '../api/policyAssociation'; import { queryConfig } from '../libs/queryConfig'; @@ -23,24 +23,25 @@ export const usePolicyAssociationsByUser = (userId: string) => { const store = useUserPolicyStore(); const countryId = useCurrentCountry(); const isLoggedIn = false; // TODO: Replace with actual auth check in future - // TODO: Should we determine user ID from auth context here? Or pass as arg? const config = isLoggedIn ? queryConfig.api : queryConfig.localStorage; return useQuery({ queryKey: policyAssociationKeys.byUser(userId, countryId), queryFn: () => store.findByUser(userId, countryId), + enabled: !!countryId, ...config, }); }; -export const usePolicyAssociation = (userId: string, policyId: string) => { +export const usePolicyAssociation = (userPolicyId: string) => { const store = useUserPolicyStore(); const isLoggedIn = false; // TODO: Replace with actual auth check in future const config = isLoggedIn ? queryConfig.api : queryConfig.localStorage; return useQuery({ - queryKey: policyAssociationKeys.specific(userId, policyId), - queryFn: () => store.findById(userId, policyId), + queryKey: policyAssociationKeys.byId(userPolicyId), + queryFn: () => store.findById(userPolicyId), + enabled: !!userPolicyId, ...config, }); }; @@ -54,21 +55,15 @@ export const useCreatePolicyAssociation = () => { onSuccess: (newAssociation) => { // Invalidate and refetch related queries queryClient.invalidateQueries({ - queryKey: policyAssociationKeys.byUser( - newAssociation.userId.toString(), - newAssociation.countryId - ), + queryKey: policyAssociationKeys.byUser(newAssociation.userId, newAssociation.countryId), }); queryClient.invalidateQueries({ - queryKey: policyAssociationKeys.byPolicy(newAssociation.policyId.toString()), + queryKey: policyAssociationKeys.byPolicy(newAssociation.policyId), }); // Update specific query cache queryClient.setQueryData( - policyAssociationKeys.specific( - newAssociation.userId.toString(), - newAssociation.policyId.toString() - ), + policyAssociationKeys.specific(newAssociation.userId, newAssociation.policyId), newAssociation ); }, @@ -78,6 +73,7 @@ export const useCreatePolicyAssociation = () => { export const useUpdatePolicyAssociation = () => { const store = useUserPolicyStore(); const queryClient = useQueryClient(); + const userId = useUserId(); return useMutation({ mutationFn: ({ @@ -86,10 +82,9 @@ export const useUpdatePolicyAssociation = () => { }: { userPolicyId: string; updates: Partial; - }) => store.update(userPolicyId, updates), + }) => store.update(userPolicyId, updates, userId), onSuccess: (updatedAssociation) => { - // Invalidate all related queries to trigger refetch queryClient.invalidateQueries({ queryKey: policyAssociationKeys.byUser( updatedAssociation.userId, @@ -110,27 +105,23 @@ export const useUpdatePolicyAssociation = () => { }); }; -// Not yet implemented, but keeping for future use -/* -export const useDeleteAssociation = () => { +export const useDeletePolicyAssociation = () => { const store = useUserPolicyStore(); const queryClient = useQueryClient(); + const userId = useUserId(); return useMutation({ - mutationFn: ({ userId, policyId }: { userId: string; policyId: string; countryId?: string }) => - store.delete(userId, policyId), - onSuccess: (_, { userId, policyId, countryId }) => { - queryClient.invalidateQueries({ queryKey: policyAssociationKeys.byUser(userId, countryId) }); + mutationFn: ({ userPolicyId, policyId }: { userPolicyId: string; policyId: string }) => + store.delete(userPolicyId, userId).then(() => ({ policyId })), + onSuccess: (_, { userPolicyId, policyId }) => { + queryClient.invalidateQueries({ queryKey: policyAssociationKeys.byUser(userId) }); queryClient.invalidateQueries({ queryKey: policyAssociationKeys.byPolicy(policyId) }); - queryClient.setQueryData( - policyAssociationKeys.specific(userId, policyId), - null - ); + queryClient.setQueryData(policyAssociationKeys.specific(userId, policyId), null); + queryClient.removeQueries({ queryKey: policyAssociationKeys.byId(userPolicyId) }); }, }); }; -*/ // Type for the combined data structure export interface UserPolicyWithAssociation { @@ -158,9 +149,7 @@ export function isPolicyWithAssociation(obj: unknown): obj is UserPolicyWithAsso } export const useUserPolicies = (userId: string) => { - const country = useCurrentCountry(); - - // First, get the associations (filtered by current country) + // First, get the associations (now filtered by countryId in both API and localStorage) const { data: associations, isLoading: associationsLoading, @@ -174,11 +163,11 @@ export const useUserPolicies = (userId: string) => { // This ensures cache consistency with useUserReports and useUserSimulations const policyQueries = useQueries({ queries: policyIds.map((policyId) => ({ - queryKey: policyKeys.byId(policyId.toString()), + queryKey: policyKeys.byId(policyId), queryFn: async () => { try { - const metadata = await fetchPolicyById(country, policyId.toString()); - return PolicyAdapter.fromMetadata(metadata); + const response = await fetchPolicyById(policyId); + return PolicyAdapter.fromV2Response(response); } catch (error) { // Add context to help debug which policy failed const message = @@ -200,6 +189,7 @@ export const useUserPolicies = (userId: string) => { const isError = !!error; // Simple index-based mapping since queries are in same order as associations + // No post-fetch filter needed - both API and localStorage now filter by countryId const policiesWithAssociations: UserPolicyWithAssociation[] | undefined = associations?.map( (association, index) => ({ association, diff --git a/app/src/hooks/useUserReports.ts b/app/src/hooks/useUserReports.ts index 0d7c9a7fa..74e48f804 100644 --- a/app/src/hooks/useUserReports.ts +++ b/app/src/hooks/useUserReports.ts @@ -163,8 +163,8 @@ export const useUserReports = (userId: string) => { const policyResults = useParallelQueries(policyIds, { queryKey: policyKeys.byId, queryFn: async (id) => { - const metadata = await fetchPolicyById(country, id); - return PolicyAdapter.fromMetadata(metadata); + const response = await fetchPolicyById(id); + return PolicyAdapter.fromV2Response(response); }, enabled: policyIds.length > 0, staleTime: 5 * 60 * 1000, @@ -414,8 +414,8 @@ export const useUserReportById = (userReportId: string, options?: { enabled?: bo const policyResults = useParallelQueries(policyIds, { queryKey: policyKeys.byId, queryFn: async (id) => { - const metadata = await fetchPolicyById(country, id); - return PolicyAdapter.fromMetadata(metadata); + const response = await fetchPolicyById(id); + return PolicyAdapter.fromV2Response(response); }, enabled: isEnabled && policyIds.length > 0, staleTime: 5 * 60 * 1000, diff --git a/app/src/hooks/useUserSimulations.ts b/app/src/hooks/useUserSimulations.ts index 8a90dbd11..92ba00e63 100644 --- a/app/src/hooks/useUserSimulations.ts +++ b/app/src/hooks/useUserSimulations.ts @@ -122,8 +122,8 @@ export const useUserSimulations = (userId: string) => { const policyResults = useParallelQueries(policyIds, { queryKey: policyKeys.byId, queryFn: async (id) => { - const metadata = await fetchPolicyById(country, id); - return PolicyAdapter.fromMetadata(metadata); + const response = await fetchPolicyById(id); + return PolicyAdapter.fromV2Response(response); }, enabled: policyIds.length > 0, staleTime: 5 * 60 * 1000, @@ -283,8 +283,8 @@ export const useUserSimulationById = (userId: string, simulationId: string) => { const { data: policy } = useQuery({ queryKey: policyKeys.byId(finalSimulation?.policyId?.toString() ?? ''), queryFn: async () => { - const metadata = await fetchPolicyById(country, finalSimulation!.policyId!.toString()); - return PolicyAdapter.fromMetadata(metadata); + const response = await fetchPolicyById(finalSimulation!.policyId!.toString()); + return PolicyAdapter.fromV2Response(response); }, enabled: !!finalSimulation?.policyId, staleTime: 5 * 60 * 1000, diff --git a/app/src/hooks/utils/useFetchReportIngredients.ts b/app/src/hooks/utils/useFetchReportIngredients.ts index 3991161d4..9f5636e4e 100644 --- a/app/src/hooks/utils/useFetchReportIngredients.ts +++ b/app/src/hooks/utils/useFetchReportIngredients.ts @@ -232,8 +232,8 @@ export function useFetchReportIngredients( const policyResults = useParallelQueries(isEnabled ? policyIds : [], { queryKey: policyKeys.byId, queryFn: async (id) => { - const metadata = await fetchPolicyById(country, id); - return PolicyAdapter.fromMetadata(metadata); + const response = await fetchPolicyById(id); + return PolicyAdapter.fromV2Response(response); }, enabled: isEnabled && policyIds.length > 0, staleTime: 5 * 60 * 1000, diff --git a/app/src/libs/queryKeys.ts b/app/src/libs/queryKeys.ts index b86e7cd8a..88f9afbe4 100644 --- a/app/src/libs/queryKeys.ts +++ b/app/src/libs/queryKeys.ts @@ -1,5 +1,6 @@ export const policyAssociationKeys = { all: ['policy-associations'] as const, + byId: (userPolicyId: string) => [...policyAssociationKeys.all, 'id', userPolicyId] as const, byUser: (userId: string, countryId?: string) => countryId ? ([...policyAssociationKeys.all, 'user_id', userId, 'country', countryId] as const) diff --git a/app/src/libs/userIdentity.ts b/app/src/libs/userIdentity.ts new file mode 100644 index 000000000..88e5f476a --- /dev/null +++ b/app/src/libs/userIdentity.ts @@ -0,0 +1,40 @@ +/** + * User Identity Module + * + * Manages persistent anonymous user IDs stored in localStorage. + * This ID is used to associate user-created records (households, policies, + * simulations, reports, geographies) with the user across sessions. + */ + +const USER_ID_STORAGE_KEY = 'policyengine_user_id'; + +/** + * Gets the current user's ID, creating one if it doesn't exist. + * The ID is a UUID stored in localStorage for persistence across sessions. + * + * @returns The user's unique identifier + */ +export function getUserId(): string { + let userId = localStorage.getItem(USER_ID_STORAGE_KEY); + if (!userId) { + userId = crypto.randomUUID(); + localStorage.setItem(USER_ID_STORAGE_KEY, userId); + } + return userId; +} + +/** + * Clears the user's ID from localStorage. + * This will cause a new ID to be generated on the next call to getUserId(). + * + * Use with caution - this will effectively create a "new user" who won't + * have access to their previously created records. + */ +export function clearUserId(): void { + localStorage.removeItem(USER_ID_STORAGE_KEY); +} + +// Export storage keys for testing purposes +export const STORAGE_KEYS = { + USER_ID: USER_ID_STORAGE_KEY, +} as const; diff --git a/app/src/pages/Policies.page.tsx b/app/src/pages/Policies.page.tsx index 4e37fda92..5d80e070d 100644 --- a/app/src/pages/Policies.page.tsx +++ b/app/src/pages/Policies.page.tsx @@ -5,14 +5,14 @@ import { useDisclosure } from '@mantine/hooks'; import { ColumnConfig, IngredientRecord, TextValue } from '@/components/columns'; import { RenameIngredientModal } from '@/components/common/RenameIngredientModal'; import IngredientReadView from '@/components/IngredientReadView'; -import { MOCK_USER_ID } from '@/constants'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { useUserId } from '@/hooks/useUserId'; import { useUpdatePolicyAssociation, useUserPolicies } from '@/hooks/useUserPolicy'; import { countPolicyModifications } from '@/utils/countParameterChanges'; import { formatDate } from '@/utils/dateUtils'; export default function PoliciesPage() { - const userId = MOCK_USER_ID.toString(); // TODO: Replace with actual user ID retrieval logic + const userId = useUserId(); const { data, isLoading, isError, error } = useUserPolicies(userId); const navigate = useNavigate(); const countryId = useCurrentCountry(); @@ -116,12 +116,7 @@ export default function PoliciesPage() { } as TextValue, dateCreated: { text: item.association.createdAt - ? formatDate( - item.association.createdAt, - 'short-month-day-year', - item.association.countryId, - true - ) + ? formatDate(item.association.createdAt, 'short-month-day-year', countryId, true) : '', } as TextValue, provisions: { diff --git a/app/src/pages/report-output/earnings-variation/EarningsVariationSubPage.tsx b/app/src/pages/report-output/earnings-variation/EarningsVariationSubPage.tsx index 23e09e58c..4ff166e0c 100644 --- a/app/src/pages/report-output/earnings-variation/EarningsVariationSubPage.tsx +++ b/app/src/pages/report-output/earnings-variation/EarningsVariationSubPage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { Group, Select, Stack, Text } from '@mantine/core'; -import { PolicyAdapter } from '@/adapters/PolicyAdapter'; +import { convertParametersToPolicyJson } from '@/adapters/conversionHelpers'; import { spacing } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useHouseholdVariation } from '@/hooks/useHouseholdVariation'; @@ -55,11 +55,13 @@ export default function EarningsVariationSubPage({ const baselinePolicy = policies?.find((p) => p.id === simulations[0]?.policyId); const reformPolicy = simulations[1] && policies?.find((p) => p.id === simulations[1].policyId); - // Convert policies to API format + // Convert policies to API format for calculate-full endpoint const baselinePolicyData = baselinePolicy - ? PolicyAdapter.toCreationPayload(baselinePolicy).data + ? convertParametersToPolicyJson(baselinePolicy.parameters || []) + : {}; + const reformPolicyData = reformPolicy + ? convertParametersToPolicyJson(reformPolicy.parameters || []) : {}; - const reformPolicyData = reformPolicy ? PolicyAdapter.toCreationPayload(reformPolicy).data : {}; // Fetch baseline variation const { diff --git a/app/src/pages/report-output/marginal-tax-rates/MarginalTaxRatesSubPage.tsx b/app/src/pages/report-output/marginal-tax-rates/MarginalTaxRatesSubPage.tsx index aba197795..dbb7fea58 100644 --- a/app/src/pages/report-output/marginal-tax-rates/MarginalTaxRatesSubPage.tsx +++ b/app/src/pages/report-output/marginal-tax-rates/MarginalTaxRatesSubPage.tsx @@ -3,7 +3,7 @@ import type { Layout } from 'plotly.js'; import Plot from 'react-plotly.js'; import { Group, Radio, Stack, Text } from '@mantine/core'; import { useMediaQuery, useViewportSize } from '@mantine/hooks'; -import { PolicyAdapter } from '@/adapters/PolicyAdapter'; +import { convertParametersToPolicyJson } from '@/adapters/conversionHelpers'; import { colors, spacing } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { useHouseholdVariation } from '@/hooks/useHouseholdVariation'; @@ -67,11 +67,13 @@ export default function MarginalTaxRatesSubPage({ const baselinePolicy = policies?.find((p) => p.id === simulations[0]?.policyId); const reformPolicy = simulations[1] && policies?.find((p) => p.id === simulations[1].policyId); - // Convert policies to API format + // Convert policies to API format for calculate-full endpoint const baselinePolicyData = baselinePolicy - ? PolicyAdapter.toCreationPayload(baselinePolicy).data + ? convertParametersToPolicyJson(baselinePolicy.parameters || []) + : {}; + const reformPolicyData = reformPolicy + ? convertParametersToPolicyJson(reformPolicy.parameters || []) : {}; - const reformPolicyData = reformPolicy ? PolicyAdapter.toCreationPayload(reformPolicy).data : {}; // Fetch baseline variation const { diff --git a/app/src/pathways/report/views/policy/PolicyExistingView.tsx b/app/src/pathways/report/views/policy/PolicyExistingView.tsx index 34b42de82..06325d02d 100644 --- a/app/src/pathways/report/views/policy/PolicyExistingView.tsx +++ b/app/src/pathways/report/views/policy/PolicyExistingView.tsx @@ -7,7 +7,7 @@ import { useState } from 'react'; import { Text } from '@mantine/core'; import PathwayView from '@/components/common/PathwayView'; -import { MOCK_USER_ID } from '@/constants'; +import { useUserId } from '@/hooks/useUserId'; import { isPolicyWithAssociation, UserPolicyWithAssociation, @@ -26,7 +26,7 @@ export default function PolicyExistingView({ onBack, onCancel, }: PolicyExistingViewProps) { - const userId = MOCK_USER_ID.toString(); + const userId = useUserId(); const { data, isLoading, isError, error } = useUserPolicies(userId); const [localPolicy, setLocalPolicy] = useState(null); diff --git a/app/src/pathways/report/views/policy/PolicySubmitView.tsx b/app/src/pathways/report/views/policy/PolicySubmitView.tsx index fe13f4bd7..6a97396c1 100644 --- a/app/src/pathways/report/views/policy/PolicySubmitView.tsx +++ b/app/src/pathways/report/views/policy/PolicySubmitView.tsx @@ -4,7 +4,6 @@ * Props-based instead of Redux-based */ -import { PolicyAdapter } from '@/adapters'; import IngredientSubmissionView, { DateIntervalValue, TextListItem, @@ -14,7 +13,6 @@ import { useCreatePolicy } from '@/hooks/useCreatePolicy'; import { countryIds } from '@/libs/countries'; import { Policy } from '@/types/ingredients/Policy'; import { PolicyStateProps } from '@/types/pathwayState'; -import { PolicyCreationPayload } from '@/types/payloads'; import { formatDate } from '@/utils/dateUtils'; interface PolicySubmitViewProps { @@ -32,7 +30,7 @@ export default function PolicySubmitView({ onBack, onCancel, }: PolicySubmitViewProps) { - const { createPolicy, isPending } = useCreatePolicy(policy?.label || undefined); + const { createPolicy, isPending, isModelReady } = useCreatePolicy(policy?.label || undefined); // Convert state to Policy type structure const policyData: Partial = { @@ -45,14 +43,20 @@ export default function PolicySubmitView({ return; } - const serializedPolicyCreationPayload: PolicyCreationPayload = PolicyAdapter.toCreationPayload( - policyData as Policy + if (!isModelReady) { + console.error('Tax benefit model not loaded yet'); + return; + } + + // Pass policy directly - hook handles conversion to v2 format + createPolicy( + { policy: policyData as Policy, name: policy.label || undefined }, + { + onSuccess: (data) => { + onSubmitSuccess(data.id); + }, + } ); - createPolicy(serializedPolicyCreationPayload, { - onSuccess: (data) => { - onSubmitSuccess(data.result.policy_id); - }, - }); } // Helper function to format date range string (UTC timezone-agnostic) diff --git a/app/src/tests/fixtures/adapters/PolicyAdapterMocks.ts b/app/src/tests/fixtures/adapters/PolicyAdapterMocks.ts index f430a4ced..a7d35a906 100644 --- a/app/src/tests/fixtures/adapters/PolicyAdapterMocks.ts +++ b/app/src/tests/fixtures/adapters/PolicyAdapterMocks.ts @@ -1,5 +1,5 @@ import type { Policy } from '@/types/ingredients/Policy'; -import type { PolicyMetadata, PolicyMetadataParams } from '@/types/metadata/policyMetadata'; +import type { PolicyMetadataParams } from '@/types/metadata/policyMetadata'; import type { Parameter } from '@/types/subIngredients/parameter'; export const TEST_POLICY_IDS = { @@ -17,36 +17,6 @@ export const TEST_PARAMETER_NAMES = { BENEFIT_AMOUNT: 'benefit_amount', } as const; -export const mockPolicyMetadata = (overrides?: Partial): PolicyMetadata => ({ - id: TEST_POLICY_IDS.POLICY_1, - country_id: TEST_COUNTRIES.US, - api_version: '1.0.0', - policy_hash: 'hash-123', - policy_json: { - tax_rate: { - '2024-01-01.2024-12-31': 0.25, - '2025-01-01.2025-12-31': 0.27, - }, - }, - ...overrides, -}); - -export const mockPolicyMetadataMultipleParams = (): PolicyMetadata => ({ - id: TEST_POLICY_IDS.POLICY_2, - country_id: TEST_COUNTRIES.UK, - api_version: '1.0.0', - policy_hash: 'hash-456', - policy_json: { - tax_rate: { - '2024-01-01.2024-12-31': 0.2, - }, - benefit_amount: { - '2024-01-01.2024-12-31': 1000, - '2025-01-01.2025-12-31': 1100, - }, - }, -}); - export const mockPolicyJson = (): PolicyMetadataParams => ({ tax_rate: { '2024-01-01.2024-12-31': 0.25, @@ -57,7 +27,6 @@ export const mockPolicyJson = (): PolicyMetadataParams => ({ export const mockPolicy = (overrides?: Partial): Policy => ({ id: '1', countryId: TEST_COUNTRIES.US, - apiVersion: '1.0.0', parameters: [ { name: TEST_PARAMETER_NAMES.TAX_RATE, diff --git a/app/src/tests/fixtures/adapters/userAssociationMocks.ts b/app/src/tests/fixtures/adapters/userAssociationMocks.ts index 689ec08b9..07ed18904 100644 --- a/app/src/tests/fixtures/adapters/userAssociationMocks.ts +++ b/app/src/tests/fixtures/adapters/userAssociationMocks.ts @@ -1,12 +1,8 @@ +import { UserPolicyAssociationV2Response } from '@/api/v2/userPolicyAssociations'; import { UserPolicy } from '@/types/ingredients/UserPolicy'; import { UserReport } from '@/types/ingredients/UserReport'; import { UserSimulation } from '@/types/ingredients/UserSimulation'; -import { UserPolicyMetadata } from '@/types/metadata/userPolicyMetadata'; -import { - UserPolicyCreationPayload, - UserReportCreationPayload, - UserSimulationCreationPayload, -} from '@/types/payloads'; +import { UserReportCreationPayload, UserSimulationCreationPayload } from '@/types/payloads'; import { TEST_COUNTRIES, TEST_LABELS, @@ -19,7 +15,7 @@ import { // UserPolicy fixtures export const mockUserPolicyUS: UserPolicy = { - id: TEST_POLICY_IDS.POLICY_789, // UserPolicyAdapter uses policyId as the id + id: 'user-policy-123', // Association ID from backend userId: TEST_USER_IDS.USER_123, policyId: TEST_POLICY_IDS.POLICY_789, countryId: TEST_COUNTRIES.US, @@ -31,9 +27,8 @@ export const mockUserPolicyUS: UserPolicy = { export const mockUserPolicyUK: UserPolicy = { ...mockUserPolicyUS, - id: TEST_POLICY_IDS.POLICY_ABC, + id: 'user-policy-456', // Association ID from backend policyId: TEST_POLICY_IDS.POLICY_ABC, - countryId: TEST_COUNTRIES.UK, }; export const mockUserPolicyWithoutOptionalFields: Omit = { @@ -43,15 +38,8 @@ export const mockUserPolicyWithoutOptionalFields: Omit): Us id: TEST_IDS.USER_POLICY_ID, userId: TEST_IDS.USER_ID, policyId: TEST_IDS.POLICY_ID, - countryId: TEST_COUNTRIES.US, label: TEST_LABELS.POLICY, createdAt: TEST_TIMESTAMPS.CREATED_AT, isCreated: true, diff --git a/app/src/tests/fixtures/api/policyMocks.ts b/app/src/tests/fixtures/api/policyMocks.ts index 00b595744..0aaedfb6a 100644 --- a/app/src/tests/fixtures/api/policyMocks.ts +++ b/app/src/tests/fixtures/api/policyMocks.ts @@ -1,4 +1,5 @@ import { vi } from 'vitest'; +import { V2PolicyCreatePayload, V2PolicyResponse } from '@/api/policy'; export const TEST_POLICY_IDS = { POLICY_123: 'policy-123', @@ -11,6 +12,34 @@ export const TEST_COUNTRIES = { UK: 'uk', } as const; +export const TEST_TAX_BENEFIT_MODEL_ID = 'test-tbm-id-123'; + +/** + * V2 Policy response mock + */ +export const mockV2PolicyResponse = (overrides?: Partial): V2PolicyResponse => ({ + id: TEST_POLICY_IDS.POLICY_123, + name: 'Test Policy', + description: null, + tax_benefit_model_id: TEST_TAX_BENEFIT_MODEL_ID, + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + ...overrides, +}); + +/** + * V2 Policy creation payload mock + */ +export const mockV2PolicyPayload = (overrides?: Partial): V2PolicyCreatePayload => ({ + name: 'New Policy', + tax_benefit_model_id: TEST_TAX_BENEFIT_MODEL_ID, + parameter_values: [], + ...overrides, +}); + +/** + * @deprecated Use mockV2PolicyResponse for v2 API + */ export const mockPolicyData = (overrides?: any) => ({ result: { id: TEST_POLICY_IDS.POLICY_123, @@ -20,12 +49,18 @@ export const mockPolicyData = (overrides?: any) => ({ }, }); +/** + * @deprecated Use mockV2PolicyPayload for v2 API + */ export const mockPolicyPayload = (overrides?: any) => ({ data: { param1: 100, param2: 200 }, label: 'New Policy', ...overrides, }); +/** + * @deprecated Use mockV2PolicyResponse for v2 API + */ export const mockPolicyCreateResponse = (policyId = TEST_POLICY_IDS.POLICY_456) => ({ result: { policy_id: policyId }, }); diff --git a/app/src/tests/fixtures/hooks/useFetchReportIngredientsMocks.ts b/app/src/tests/fixtures/hooks/useFetchReportIngredientsMocks.ts index 9a9668643..7b45e94e9 100644 --- a/app/src/tests/fixtures/hooks/useFetchReportIngredientsMocks.ts +++ b/app/src/tests/fixtures/hooks/useFetchReportIngredientsMocks.ts @@ -64,8 +64,8 @@ export const SOCIETY_WIDE_INPUT: ReportIngredientsInput = { { simulationId: TEST_IDS.SIMULATIONS.REFORM, countryId: TEST_COUNTRIES.US, label: 'Reform' }, ], userPolicies: [ - { policyId: TEST_IDS.POLICIES.CURRENT_LAW, countryId: TEST_COUNTRIES.US, label: 'Current Law' }, - { policyId: TEST_IDS.POLICIES.REFORM, countryId: TEST_COUNTRIES.US, label: 'My Reform' }, + { policyId: TEST_IDS.POLICIES.CURRENT_LAW, label: 'Current Law' }, + { policyId: TEST_IDS.POLICIES.REFORM, label: 'My Reform' }, ], userHouseholds: [], userGeographies: [ @@ -92,7 +92,7 @@ export const HOUSEHOLD_INPUT: ReportIngredientsInput = { userSimulations: [ { simulationId: 'sim-hh-1', countryId: TEST_COUNTRIES.UK, label: 'Household Sim' }, ], - userPolicies: [{ policyId: 'policy-hh-1', countryId: TEST_COUNTRIES.UK, label: 'HH Policy' }], + userPolicies: [{ policyId: 'policy-hh-1', label: 'HH Policy' }], userHouseholds: [ { type: 'household', diff --git a/app/src/tests/fixtures/hooks/useSaveSharedReportMocks.ts b/app/src/tests/fixtures/hooks/useSaveSharedReportMocks.ts index 789949083..15ef2a74d 100644 --- a/app/src/tests/fixtures/hooks/useSaveSharedReportMocks.ts +++ b/app/src/tests/fixtures/hooks/useSaveSharedReportMocks.ts @@ -44,7 +44,7 @@ export const MOCK_SAVE_SHARE_DATA: ReportIngredientsInput = { userSimulations: [ { simulationId: TEST_IDS.SIMULATION, countryId: TEST_COUNTRIES.US, label: 'Baseline' }, ], - userPolicies: [{ policyId: TEST_IDS.POLICY, countryId: TEST_COUNTRIES.US, label: 'My Policy' }], + userPolicies: [{ policyId: TEST_IDS.POLICY, label: 'My Policy' }], userHouseholds: [], userGeographies: [ { @@ -60,8 +60,8 @@ export const MOCK_SAVE_SHARE_DATA: ReportIngredientsInput = { export const MOCK_SHARE_DATA_WITH_CURRENT_LAW: ReportIngredientsInput = { ...MOCK_SAVE_SHARE_DATA, userPolicies: [ - { policyId: TEST_IDS.CURRENT_LAW_POLICY, countryId: TEST_COUNTRIES.US, label: 'Current Law' }, - { policyId: TEST_IDS.POLICY, countryId: TEST_COUNTRIES.US, label: 'My Policy' }, + { policyId: TEST_IDS.CURRENT_LAW_POLICY, label: 'Current Law' }, + { policyId: TEST_IDS.POLICY, label: 'My Policy' }, ], }; diff --git a/app/src/tests/fixtures/hooks/useSharedReportDataMocks.tsx b/app/src/tests/fixtures/hooks/useSharedReportDataMocks.tsx index 038f88b36..2c0b27b3a 100644 --- a/app/src/tests/fixtures/hooks/useSharedReportDataMocks.tsx +++ b/app/src/tests/fixtures/hooks/useSharedReportDataMocks.tsx @@ -18,7 +18,7 @@ export const MOCK_SHARE_DATA: ReportIngredientsInput = { label: 'Test Report', }, userSimulations: [{ simulationId: 'sim-1', countryId: 'us', label: 'Baseline Sim' }], - userPolicies: [{ policyId: 'policy-1', countryId: 'us', label: 'Test Policy' }], + userPolicies: [{ policyId: 'policy-1', label: 'Test Policy' }], userHouseholds: [], userGeographies: [ { @@ -39,7 +39,7 @@ export const MOCK_HOUSEHOLD_SHARE_DATA: ReportIngredientsInput = { label: 'Household Report', }, userSimulations: [{ simulationId: 'sim-2', countryId: 'uk', label: 'HH Sim' }], - userPolicies: [{ policyId: 'policy-2', countryId: 'uk', label: 'HH Policy' }], + userPolicies: [{ policyId: 'policy-2', label: 'HH Policy' }], userHouseholds: [ { type: 'household', householdId: 'hh-1', countryId: 'uk', label: 'My Household' }, ], diff --git a/app/src/tests/fixtures/hooks/useUserPolicyMocks.ts b/app/src/tests/fixtures/hooks/useUserPolicyMocks.ts index 85062c918..c584bcabd 100644 --- a/app/src/tests/fixtures/hooks/useUserPolicyMocks.ts +++ b/app/src/tests/fixtures/hooks/useUserPolicyMocks.ts @@ -36,21 +36,6 @@ export const mockUserPolicyAssociation2: UserPolicy = { export const mockUserPolicyAssociations = [mockUserPolicyAssociation1, mockUserPolicyAssociation2]; -// Mock policy metadata (API response format) -export const mockPolicyMetadata1 = { - id: 456, - country_id: TEST_COUNTRY_ID, - api_version: 'v1', - policy_json: {}, -}; - -export const mockPolicyMetadata2 = { - id: 789, - country_id: TEST_COUNTRY_ID, - api_version: 'v1', - policy_json: {}, -}; - // Mock hook return values export const createMockAssociationsHookReturn = () => ({ data: mockUserPolicyAssociations, diff --git a/app/src/tests/fixtures/hooks/useUserReportsMocks.ts b/app/src/tests/fixtures/hooks/useUserReportsMocks.ts index cbe5516f3..0e216468e 100644 --- a/app/src/tests/fixtures/hooks/useUserReportsMocks.ts +++ b/app/src/tests/fixtures/hooks/useUserReportsMocks.ts @@ -1,4 +1,5 @@ import { vi } from 'vitest'; +import { V2PolicyResponse } from '@/api/policy'; import { Household } from '@/types/ingredients/Household'; import { Policy } from '@/types/ingredients/Policy'; import { Simulation } from '@/types/ingredients/Simulation'; @@ -7,9 +8,7 @@ import { UserHouseholdPopulation } from '@/types/ingredients/UserPopulation'; import { UserSimulation } from '@/types/ingredients/UserSimulation'; import { MetadataState } from '@/types/metadata'; import { HouseholdMetadata } from '@/types/metadata/householdMetadata'; -import { PolicyMetadata } from '@/types/metadata/policyMetadata'; import { SimulationMetadata } from '@/types/metadata/simulationMetadata'; -import { US_REGION_TYPES } from '@/types/regionTypes'; import { mockReport } from '../adapters/reportMocks'; import { TEST_USER_ID } from '../api/reportAssociationMocks'; import { DEFAULT_LOADING_STATES } from '../reducers/metadataReducerMocks'; @@ -50,7 +49,7 @@ export const mockSimulation2: Simulation = { isCreated: true, }; -// Mock Policy entities (matching PolicyAdapter.fromMetadata structure) +// Mock Policy entities (matching PolicyAdapter.fromV2Response structure) export const mockPolicy1: Policy = { id: TEST_POLICY_ID_1, countryId: TEST_COUNTRIES.US, @@ -104,17 +103,17 @@ export const mockUserPolicies: UserPolicy[] = [ id: 'user-pol-1', userId: TEST_USER_ID, policyId: TEST_POLICY_ID_1, + countryId: TEST_COUNTRIES.US, label: 'My Policy 1', createdAt: '2025-01-01T09:00:00Z', - countryId: 'us', }, { id: 'user-pol-2', userId: TEST_USER_ID, policyId: TEST_POLICY_ID_2, + countryId: TEST_COUNTRIES.US, label: 'My Policy 2', createdAt: '2025-01-02T09:00:00Z', - countryId: 'us', }, ]; @@ -149,22 +148,23 @@ export const mockSimulationMetadata2: SimulationMetadata = { policy_id: TEST_POLICY_ID_2, // policy-789 }; -export const mockPolicyMetadata1: PolicyMetadata = { +// V2 API Policy Responses +export const mockV2PolicyResponse1: V2PolicyResponse = { id: TEST_POLICY_ID_1, - country_id: TEST_COUNTRIES.US, - api_version: 'v1', - policy_json: {}, - policy_hash: 'hash-456', - label: 'Test Policy 1', + name: 'Test Policy 1', + description: null, + tax_benefit_model_id: 'test-tbm-id', + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', }; -export const mockPolicyMetadata2: PolicyMetadata = { +export const mockV2PolicyResponse2: V2PolicyResponse = { id: TEST_POLICY_ID_2, - country_id: TEST_COUNTRIES.US, - api_version: 'v1', - policy_json: {}, - policy_hash: 'hash-789', - label: 'Test Policy 2', + name: 'Test Policy 2', + description: null, + tax_benefit_model_id: 'test-tbm-id', + created_at: '2025-01-02T00:00:00Z', + updated_at: '2025-01-02T00:00:00Z', }; export const mockHouseholdMetadata: HouseholdMetadata = { diff --git a/app/src/tests/fixtures/pages/policiesMocks.ts b/app/src/tests/fixtures/pages/policiesMocks.ts index db81f5555..4519ed63e 100644 --- a/app/src/tests/fixtures/pages/policiesMocks.ts +++ b/app/src/tests/fixtures/pages/policiesMocks.ts @@ -31,7 +31,6 @@ export const mockPolicyData: UserPolicyWithAssociation[] = [ policyId: '101', label: 'Test Policy 1', createdAt: '2024-01-15T10:00:00Z', - countryId: 'us', }, policy: { id: '101', @@ -62,7 +61,6 @@ export const mockPolicyData: UserPolicyWithAssociation[] = [ policyId: '102', label: 'Test Policy 2', createdAt: '2024-02-20T14:30:00Z', - countryId: 'us', }, policy: { id: '102', diff --git a/app/src/tests/fixtures/pages/report-output/PolicySubPage.ts b/app/src/tests/fixtures/pages/report-output/PolicySubPage.ts index 3746215cc..c88f782a2 100644 --- a/app/src/tests/fixtures/pages/report-output/PolicySubPage.ts +++ b/app/src/tests/fixtures/pages/report-output/PolicySubPage.ts @@ -153,7 +153,6 @@ export const mockUserBaselinePolicy: UserPolicy = { id: 'user-pol-baseline-123', userId: TEST_USER_ID, policyId: TEST_POLICY_IDS.BASELINE, - countryId: 'us', label: 'My Baseline Policy', createdAt: '2025-01-15T10:00:00Z', }; @@ -162,7 +161,6 @@ export const mockUserReformPolicy: UserPolicy = { id: 'user-pol-reform-456', userId: TEST_USER_ID, policyId: TEST_POLICY_IDS.REFORM, - countryId: 'us', label: 'My Reform Policy', createdAt: '2025-01-15T11:00:00Z', }; diff --git a/app/src/tests/fixtures/pages/report-output/SocietyWideReportOutput.ts b/app/src/tests/fixtures/pages/report-output/SocietyWideReportOutput.ts index b11873f94..3a96167ed 100644 --- a/app/src/tests/fixtures/pages/report-output/SocietyWideReportOutput.ts +++ b/app/src/tests/fixtures/pages/report-output/SocietyWideReportOutput.ts @@ -72,7 +72,6 @@ export const MOCK_USER_POLICY: UserPolicy = { userId: 'user-123', policyId: 'policy-1', label: 'My Policy', - countryId: 'us', createdAt: '2024-01-01T00:00:00Z', }; diff --git a/app/src/tests/fixtures/utils/countParameterChangesMocks.ts b/app/src/tests/fixtures/utils/countParameterChangesMocks.ts deleted file mode 100644 index 0018fc406..000000000 --- a/app/src/tests/fixtures/utils/countParameterChangesMocks.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { PolicyMetadata } from '@/types/metadata/policyMetadata'; - -export const mockPolicyWithNoJson: PolicyMetadata = { - id: '1', - country_id: 'us', - api_version: 'v1', - policy_json: {}, - policy_hash: 'abc123', -}; - -export const mockPolicyWithOneParameterOneRange: PolicyMetadata = { - id: '1', - country_id: 'us', - api_version: 'v1', - policy_json: { - 'gov.irs.credits.ctc.amount.base': { - '2024-01-01.2024-12-31': 3000, - }, - }, - policy_hash: 'abc123', -}; - -export const mockPolicyWithOneParameterMultipleRanges: PolicyMetadata = { - id: '1', - country_id: 'us', - api_version: 'v1', - policy_json: { - 'gov.irs.credits.ctc.amount.base': { - '2024-01-01.2024-12-31': 3000, - '2025-01-01.2025-12-31': 3500, - '2026-01-01.2026-12-31': 4000, - }, - }, - policy_hash: 'abc123', -}; - -export const mockPolicyWithMultipleParameters: PolicyMetadata = { - id: '1', - country_id: 'us', - api_version: 'v1', - policy_json: { - 'gov.irs.credits.ctc.amount.base': { - '2024-01-01.2024-12-31': 3000, - '2025-01-01.2025-12-31': 3500, - }, - 'gov.irs.credits.eitc.max': { - '2024-01-01.2024-12-31': 6000, - '2025-01-01.2025-12-31': 6500, - '2026-01-01.2026-12-31': 7000, - }, - 'gov.irs.income.standard_deduction': { - '2024-01-01.2024-12-31': 13850, - }, - }, - policy_hash: 'abc123', -}; - -export const mockPolicyWithNullParameter: PolicyMetadata = { - id: '1', - country_id: 'us', - api_version: 'v1', - policy_json: { - 'gov.irs.credits.ctc.amount.base': null as any, - }, - policy_hash: 'abc123', -}; - -export const mockPolicyWithEmptyParameter: PolicyMetadata = { - id: '1', - country_id: 'us', - api_version: 'v1', - policy_json: { - 'gov.irs.credits.ctc.amount.base': {}, - }, - policy_hash: 'abc123', -}; diff --git a/app/src/tests/fixtures/utils/policyColumnHeaders.ts b/app/src/tests/fixtures/utils/policyColumnHeaders.ts index 5c28c8805..31fe8a7a4 100644 --- a/app/src/tests/fixtures/utils/policyColumnHeaders.ts +++ b/app/src/tests/fixtures/utils/policyColumnHeaders.ts @@ -23,7 +23,6 @@ export const MOCK_USER_POLICY_BASELINE: UserPolicy = { userId: 'user-123', policyId: 'policy-1', label: 'My Baseline', - countryId: 'us', createdAt: '2024-01-01T00:00:00Z', }; @@ -32,7 +31,6 @@ export const MOCK_USER_POLICY_REFORM: UserPolicy = { userId: 'user-123', policyId: 'policy-2', label: 'My Reform', - countryId: 'us', createdAt: '2024-01-01T00:00:00Z', }; diff --git a/app/src/tests/fixtures/utils/shareUtilsMocks.ts b/app/src/tests/fixtures/utils/shareUtilsMocks.ts index 573e1ab7d..3ef23932a 100644 --- a/app/src/tests/fixtures/utils/shareUtilsMocks.ts +++ b/app/src/tests/fixtures/utils/shareUtilsMocks.ts @@ -48,8 +48,8 @@ export const VALID_SHARE_DATA: ReportIngredientsInput = { { simulationId: 'sim-2', countryId: TEST_COUNTRIES.US, label: 'Reform' }, ], userPolicies: [ - { policyId: 'policy-1', countryId: TEST_COUNTRIES.US, label: 'Current Law' }, - { policyId: 'policy-2', countryId: TEST_COUNTRIES.US, label: 'My Policy' }, + { policyId: 'policy-1', label: 'Current Law' }, + { policyId: 'policy-2', label: 'My Policy' }, ], userHouseholds: [], userGeographies: [ @@ -77,7 +77,7 @@ export const VALID_HOUSEHOLD_SHARE_DATA: ReportIngredientsInput = { userSimulations: [ { simulationId: 'sim-3', countryId: TEST_COUNTRIES.UK, label: 'My Simulation' }, ], - userPolicies: [{ policyId: 'policy-3', countryId: TEST_COUNTRIES.UK, label: 'My Policy' }], + userPolicies: [{ policyId: 'policy-3', label: 'My Policy' }], userHouseholds: [ { type: 'household', @@ -114,7 +114,6 @@ export const MOCK_USER_POLICIES: UserPolicy[] = [ { userId: 'anonymous', policyId: 'policy-1', - countryId: TEST_COUNTRIES.US, label: 'Policy Label', }, ]; diff --git a/app/src/tests/unit/adapters/PolicyAdapter.test.ts b/app/src/tests/unit/adapters/PolicyAdapter.test.ts index 081bf812c..55f787719 100644 --- a/app/src/tests/unit/adapters/PolicyAdapter.test.ts +++ b/app/src/tests/unit/adapters/PolicyAdapter.test.ts @@ -1,120 +1,131 @@ import { describe, expect, it } from 'vitest'; import { PolicyAdapter } from '@/adapters/PolicyAdapter'; -import { - mockPolicy, - mockPolicyMetadata, - mockPolicyMetadataMultipleParams, - TEST_COUNTRIES, - TEST_PARAMETER_NAMES, - TEST_POLICY_IDS, -} from '@/tests/fixtures/adapters/PolicyAdapterMocks'; +import { V2PolicyResponse } from '@/api/policy'; +import { mockPolicy, TEST_PARAMETER_NAMES } from '@/tests/fixtures/adapters/PolicyAdapterMocks'; describe('PolicyAdapter', () => { - describe('fromMetadata', () => { - it('given policy metadata then converts to Policy', () => { + describe('fromV2Response', () => { + it('given v2 response then converts to Policy', () => { // Given - const metadata = mockPolicyMetadata(); + const response: V2PolicyResponse = { + id: 'policy-uuid-123', + name: 'Test policy', + description: 'A test policy', + tax_benefit_model_id: 'model-uuid-456', + created_at: '2025-01-15T10:00:00Z', + updated_at: '2025-01-15T10:00:00Z', + }; // When - const result = PolicyAdapter.fromMetadata(metadata); + const result = PolicyAdapter.fromV2Response(response); // Then - expect(result).toEqual({ - id: TEST_POLICY_IDS.POLICY_1, - countryId: TEST_COUNTRIES.US, - apiVersion: '1.0.0', - parameters: [ - { - name: TEST_PARAMETER_NAMES.TAX_RATE, - values: [ - { startDate: '2024-01-01', endDate: '2024-12-31', value: 0.25 }, - { startDate: '2025-01-01', endDate: '2025-12-31', value: 0.27 }, - ], - }, - ], - }); + expect(result.id).toBe('policy-uuid-123'); + expect(result.taxBenefitModelId).toBe('model-uuid-456'); + expect(result.parameters).toEqual([]); }); - it('given metadata with multiple parameters then converts all', () => { + it('given v2 response with null description then converts without error', () => { // Given - const metadata = mockPolicyMetadataMultipleParams(); + const response: V2PolicyResponse = { + id: 'policy-uuid-789', + name: 'Unnamed policy', + description: null, + tax_benefit_model_id: 'model-uuid-456', + created_at: '2025-01-15T10:00:00Z', + updated_at: '2025-01-15T10:00:00Z', + }; // When - const result = PolicyAdapter.fromMetadata(metadata); + const result = PolicyAdapter.fromV2Response(response); // Then - expect(result.parameters).toHaveLength(2); - expect(result.parameters?.[0].name).toBe(TEST_PARAMETER_NAMES.TAX_RATE); - expect(result.parameters?.[1].name).toBe(TEST_PARAMETER_NAMES.BENEFIT_AMOUNT); - expect(result.parameters?.[1].values).toHaveLength(2); + expect(result.id).toBe('policy-uuid-789'); + expect(result.parameters).toEqual([]); }); + }); - it('given empty policy_json then converts to empty parameters', () => { + describe('toV2CreationPayload', () => { + it('given policy with parameters then creates v2 payload', () => { // Given - const metadata = mockPolicyMetadata({ policy_json: {} }); + const policy = mockPolicy(); + const parametersMetadata = { + [TEST_PARAMETER_NAMES.TAX_RATE]: { + id: 'param-uuid-001', + parameter: TEST_PARAMETER_NAMES.TAX_RATE, + }, + }; + const taxBenefitModelId = 'model-uuid-456'; // When - const result = PolicyAdapter.fromMetadata(metadata); + const payload = PolicyAdapter.toV2CreationPayload( + policy, + parametersMetadata as any, + taxBenefitModelId, + 'My policy' + ); // Then - expect(result.parameters).toEqual([]); + expect(payload.name).toBe('My policy'); + expect(payload.tax_benefit_model_id).toBe('model-uuid-456'); + expect(payload.parameter_values).toHaveLength(2); + expect(payload.parameter_values[0]).toEqual({ + parameter_id: 'param-uuid-001', + value_json: 0.25, + start_date: '2024-01-01T00:00:00Z', + end_date: '2024-12-31T00:00:00Z', + }); }); - it('given UK policy then uses correct country', () => { + it('given no name then defaults to "Unnamed policy"', () => { // Given - const metadata = mockPolicyMetadata({ country_id: TEST_COUNTRIES.UK }); + const policy = mockPolicy({ parameters: [] }); // When - const result = PolicyAdapter.fromMetadata(metadata); + const payload = PolicyAdapter.toV2CreationPayload(policy, {}, 'model-id'); // Then - expect(result.countryId).toBe(TEST_COUNTRIES.UK); + expect(payload.name).toBe('Unnamed policy'); }); - }); - describe('toCreationPayload', () => { - it('given policy with parameters then creates payload', () => { + it('given policy with far-future end date then converts to null', () => { // Given - const policy = mockPolicy(); - - // When - const payload = PolicyAdapter.toCreationPayload(policy); - - // Then - expect(payload).toEqual({ - data: { - tax_rate: { - '2024-01-01.2024-12-31': 0.25, - '2025-01-01.2025-12-31': 0.27, + const policy = mockPolicy({ + parameters: [ + { + name: TEST_PARAMETER_NAMES.TAX_RATE, + values: [{ startDate: '2025-01-01', endDate: '9999-12-31', value: 0.3 }], }, - }, + ], }); - }); - - it('given policy with no parameters then creates empty payload', () => { - // Given - const policy = mockPolicy({ parameters: [] }); + const parametersMetadata = { + [TEST_PARAMETER_NAMES.TAX_RATE]: { + id: 'param-uuid-001', + parameter: TEST_PARAMETER_NAMES.TAX_RATE, + }, + }; // When - const payload = PolicyAdapter.toCreationPayload(policy); + const payload = PolicyAdapter.toV2CreationPayload( + policy, + parametersMetadata as any, + 'model-id' + ); // Then - expect(payload).toEqual({ - data: {}, - }); + expect(payload.parameter_values[0].end_date).toBeNull(); }); - it('given policy with undefined parameters then creates empty payload', () => { + it('given unknown parameter name then skips it', () => { // Given - const policy = mockPolicy({ parameters: undefined }); + const policy = mockPolicy(); + const parametersMetadata = {}; // No matching metadata // When - const payload = PolicyAdapter.toCreationPayload(policy); + const payload = PolicyAdapter.toV2CreationPayload(policy, parametersMetadata, 'model-id'); // Then - expect(payload).toEqual({ - data: {}, - }); + expect(payload.parameter_values).toHaveLength(0); }); }); }); diff --git a/app/src/tests/unit/adapters/UserPolicyAdapter.test.ts b/app/src/tests/unit/adapters/UserPolicyAdapter.test.ts deleted file mode 100644 index 24376a223..000000000 --- a/app/src/tests/unit/adapters/UserPolicyAdapter.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { UserPolicyAdapter } from '@/adapters/UserPolicyAdapter'; -import { - mockUserPolicyApiResponse, - mockUserPolicyCreationPayload, - mockUserPolicyUS, - mockUserPolicyWithoutOptionalFields, - TEST_COUNTRIES, - TEST_LABELS, - TEST_POLICY_IDS, - TEST_TIMESTAMPS, - TEST_USER_IDS, -} from '@/tests/fixtures'; -import { UserPolicy } from '@/types/ingredients/UserPolicy'; - -describe('UserPolicyAdapter', () => { - describe('toCreationPayload', () => { - test('given UserPolicy with all fields then creates proper payload', () => { - // Given - const userPolicy: Omit = { - userId: TEST_USER_IDS.USER_123, - policyId: TEST_POLICY_IDS.POLICY_789, - countryId: TEST_COUNTRIES.US, - label: TEST_LABELS.MY_POLICY, - updatedAt: TEST_TIMESTAMPS.UPDATED_AT, - isCreated: true, - }; - - // When - const result = UserPolicyAdapter.toCreationPayload(userPolicy); - - // Then - expect(result).toEqual(mockUserPolicyCreationPayload); - }); - - test('given UserPolicy without updatedAt then generates timestamp', () => { - // Given - const userPolicy: Omit = { - userId: TEST_USER_IDS.USER_123, - policyId: TEST_POLICY_IDS.POLICY_789, - countryId: TEST_COUNTRIES.US, - label: TEST_LABELS.MY_POLICY, - isCreated: true, - }; - - // When - const result = UserPolicyAdapter.toCreationPayload(userPolicy); - - // Then - expect(result.user_id).toBe(TEST_USER_IDS.USER_123); - expect(result.policy_id).toBe(TEST_POLICY_IDS.POLICY_789); - expect(result.country_id).toBe(TEST_COUNTRIES.US); - expect(result.label).toBe(TEST_LABELS.MY_POLICY); - expect(result.updated_at).toBeDefined(); - expect(new Date(result.updated_at as string).toISOString()).toBe(result.updated_at); - }); - - test('given UserPolicy with numeric IDs then converts to strings', () => { - // Given - const userPolicy: Omit = { - userId: 123 as any, - policyId: 456 as any, - countryId: TEST_COUNTRIES.US, - label: TEST_LABELS.MY_POLICY, - isCreated: true, - }; - - // When - const result = UserPolicyAdapter.toCreationPayload(userPolicy); - - // Then - expect(result.user_id).toBe('123'); - expect(result.policy_id).toBe('456'); - expect(result.country_id).toBe(TEST_COUNTRIES.US); - }); - - test('given UserPolicy without label then includes undefined label', () => { - // Given - const userPolicy = mockUserPolicyWithoutOptionalFields; - - // When - const result = UserPolicyAdapter.toCreationPayload(userPolicy); - - // Then - expect(result.label).toBeUndefined(); - expect(result.country_id).toBe(TEST_COUNTRIES.US); - }); - - test('given UserPolicy with UK country then preserves country ID', () => { - // Given - const userPolicy: Omit = { - userId: TEST_USER_IDS.USER_123, - policyId: TEST_POLICY_IDS.POLICY_ABC, - countryId: TEST_COUNTRIES.UK, - label: TEST_LABELS.MY_POLICY, - isCreated: true, - }; - - // When - const result = UserPolicyAdapter.toCreationPayload(userPolicy); - - // Then - expect(result.country_id).toBe(TEST_COUNTRIES.UK); - }); - }); - - describe('fromApiResponse', () => { - test('given API response with all fields then creates UserPolicy', () => { - // Given - const apiData = mockUserPolicyApiResponse; - - // When - const result = UserPolicyAdapter.fromApiResponse(apiData); - - // Then - expect(result).toEqual(mockUserPolicyUS); - }); - - test('given API response without optional fields then creates UserPolicy with defaults', () => { - // Given - const apiData = { - policy_id: TEST_POLICY_IDS.POLICY_789, - user_id: TEST_USER_IDS.USER_123, - country_id: TEST_COUNTRIES.US, - }; - - // When - const result = UserPolicyAdapter.fromApiResponse(apiData); - - // Then - expect(result.id).toBe(TEST_POLICY_IDS.POLICY_789); - expect(result.userId).toBe(TEST_USER_IDS.USER_123); - expect(result.policyId).toBe(TEST_POLICY_IDS.POLICY_789); - expect(result.countryId).toBe(TEST_COUNTRIES.US); - expect(result.label).toBeUndefined(); - expect(result.createdAt).toBeUndefined(); - expect(result.updatedAt).toBeUndefined(); - expect(result.isCreated).toBe(true); - }); - - test('given API response with null label then converts to undefined', () => { - // Given - const apiData = { - policy_id: TEST_POLICY_IDS.POLICY_789, - user_id: TEST_USER_IDS.USER_123, - country_id: TEST_COUNTRIES.US, - label: null, - created_at: TEST_TIMESTAMPS.CREATED_AT, - updated_at: TEST_TIMESTAMPS.UPDATED_AT, - }; - - // When - const result = UserPolicyAdapter.fromApiResponse(apiData); - - // Then - expect(result.label).toBeUndefined(); - expect(result.countryId).toBe(TEST_COUNTRIES.US); - }); - - test('given API response with UK country then preserves country ID', () => { - // Given - const apiData = { - ...mockUserPolicyApiResponse, - country_id: TEST_COUNTRIES.UK, - }; - - // When - const result = UserPolicyAdapter.fromApiResponse(apiData); - - // Then - expect(result.countryId).toBe(TEST_COUNTRIES.UK); - }); - }); -}); diff --git a/app/src/tests/unit/api/policy.test.ts b/app/src/tests/unit/api/policy.test.ts index aa432caa9..daea7407a 100644 --- a/app/src/tests/unit/api/policy.test.ts +++ b/app/src/tests/unit/api/policy.test.ts @@ -1,35 +1,34 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createPolicy, fetchPolicyById } from '@/api/policy'; -import { BASE_URL } from '@/constants'; +import { API_V2_BASE_URL } from '@/api/v2/taxBenefitModels'; import { mockErrorFetchResponse, - mockPolicyCreateResponse, - mockPolicyData, - mockPolicyPayload, mockSuccessFetchResponse, - TEST_COUNTRIES, + mockV2PolicyPayload, + mockV2PolicyResponse, TEST_POLICY_IDS, } from '@/tests/fixtures/api/policyMocks'; // Mock fetch global.fetch = vi.fn(); -describe('policy API', () => { +describe('policy API (v2)', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('fetchPolicyById', () => { - it('given valid policy ID then fetches policy metadata', async () => { + it('given valid policy ID then fetches policy from v2 API', async () => { // Given - (global.fetch as any).mockResolvedValue(mockSuccessFetchResponse(mockPolicyData())); + const mockResponse = mockV2PolicyResponse(); + (global.fetch as any).mockResolvedValue(mockSuccessFetchResponse(mockResponse)); // When - const result = await fetchPolicyById(TEST_COUNTRIES.US, TEST_POLICY_IDS.POLICY_123); + const result = await fetchPolicyById(TEST_POLICY_IDS.POLICY_123); // Then expect(fetch).toHaveBeenCalledWith( - `${BASE_URL}/${TEST_COUNTRIES.US}/policy/${TEST_POLICY_IDS.POLICY_123}`, + `${API_V2_BASE_URL}/policies/${TEST_POLICY_IDS.POLICY_123}`, expect.objectContaining({ method: 'GET', headers: { @@ -38,7 +37,7 @@ describe('policy API', () => { }, }) ); - expect(result).toEqual(mockPolicyData().result); + expect(result).toEqual(mockResponse); }); it('given fetch error then throws error', async () => { @@ -46,26 +45,26 @@ describe('policy API', () => { (global.fetch as any).mockResolvedValue(mockErrorFetchResponse(404)); // When/Then - await expect(fetchPolicyById(TEST_COUNTRIES.US, TEST_POLICY_IDS.NONEXISTENT)).rejects.toThrow( + await expect(fetchPolicyById(TEST_POLICY_IDS.NONEXISTENT)).rejects.toThrow( `Failed to fetch policy ${TEST_POLICY_IDS.NONEXISTENT}` ); }); }); describe('createPolicy', () => { - it('given valid policy data then creates policy', async () => { + it('given valid v2 payload then creates policy', async () => { // Given - const payload = mockPolicyPayload(); - const response = mockPolicyCreateResponse(); + const payload = mockV2PolicyPayload(); + const response = mockV2PolicyResponse({ id: TEST_POLICY_IDS.POLICY_456 }); (global.fetch as any).mockResolvedValue(mockSuccessFetchResponse(response)); // When - const result = await createPolicy(TEST_COUNTRIES.US, payload); + const result = await createPolicy(payload); // Then expect(fetch).toHaveBeenCalledWith( - `${BASE_URL}/${TEST_COUNTRIES.US}/policy`, + `${API_V2_BASE_URL}/policies/`, expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -75,16 +74,18 @@ describe('policy API', () => { expect(result).toEqual(response); }); - it('given API error then throws error', async () => { + it('given API error then throws error with status', async () => { // Given - const payload = mockPolicyPayload(); + const payload = mockV2PolicyPayload(); - (global.fetch as any).mockResolvedValue(mockErrorFetchResponse(500)); + (global.fetch as any).mockResolvedValue({ + ok: false, + status: 500, + text: vi.fn().mockResolvedValue('Internal Server Error'), + }); // When/Then - await expect(createPolicy(TEST_COUNTRIES.US, payload)).rejects.toThrow( - 'Failed to create policy' - ); + await expect(createPolicy(payload)).rejects.toThrow('Failed to create policy: 500'); }); }); }); diff --git a/app/src/tests/unit/api/policyAssociation.test.ts b/app/src/tests/unit/api/policyAssociation.test.ts index 532526bca..9b670bb4a 100644 --- a/app/src/tests/unit/api/policyAssociation.test.ts +++ b/app/src/tests/unit/api/policyAssociation.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiPolicyStore, LocalStoragePolicyStore } from '@/api/policyAssociation'; +import { API_V2_BASE_URL } from '@/api/v2/taxBenefitModels'; import type { UserPolicy } from '@/types/ingredients/UserPolicy'; // Mock fetch @@ -17,7 +18,7 @@ describe('ApiPolicyStore', () => { }; const mockApiResponse = { - id: 'policy-456', + id: 'user-policy-abc123', user_id: 'user-123', policy_id: 'policy-456', country_id: 'us', @@ -48,16 +49,15 @@ describe('ApiPolicyStore', () => { // Then expect(fetch).toHaveBeenCalledWith( - '/api/user-policy-associations', + `${API_V2_BASE_URL}/user-policies/`, expect.objectContaining({ method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, }) ); expect(result).toMatchObject({ userId: 'user-123', policyId: 'policy-456', - countryId: 'us', label: 'Test Policy', }); }); @@ -67,6 +67,7 @@ describe('ApiPolicyStore', () => { (global.fetch as any).mockResolvedValue({ ok: false, status: 500, + text: async () => 'Internal Server Error', }); // When/Then @@ -89,9 +90,9 @@ describe('ApiPolicyStore', () => { // Then expect(fetch).toHaveBeenCalledWith( - '/api/user-policy-associations/user/user-123', + `${API_V2_BASE_URL}/user-policies/?user_id=user-123`, expect.objectContaining({ - headers: { 'Content-Type': 'application/json' }, + headers: { Accept: 'application/json' }, }) ); expect(result).toHaveLength(1); @@ -103,22 +104,43 @@ describe('ApiPolicyStore', () => { }); }); + it('given valid user ID and country ID then fetches filtered associations', async () => { + // Given + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => [mockApiResponse], + }); + + // When + const result = await store.findByUser('user-123', 'us'); + + // Then + expect(fetch).toHaveBeenCalledWith( + `${API_V2_BASE_URL}/user-policies/?user_id=user-123&country_id=us`, + expect.objectContaining({ + headers: { Accept: 'application/json' }, + }) + ); + expect(result).toHaveLength(1); + }); + it('given API error then throws error', async () => { // Given (global.fetch as any).mockResolvedValue({ ok: false, status: 500, + text: async () => 'Internal Server Error', }); // When/Then await expect(store.findByUser('user-123')).rejects.toThrow( - 'Failed to fetch user associations' + 'Failed to fetch user policy associations' ); }); }); describe('findById', () => { - it('given valid IDs then fetches specific association', async () => { + it('given valid userPolicyId then fetches specific association', async () => { // Given (global.fetch as any).mockResolvedValue({ ok: true, @@ -127,19 +149,18 @@ describe('ApiPolicyStore', () => { }); // When - const result = await store.findById('user-123', 'policy-456'); + const result = await store.findById('user-policy-abc123'); // Then expect(fetch).toHaveBeenCalledWith( - '/api/user-policy-associations/user-123/policy-456', + `${API_V2_BASE_URL}/user-policies/user-policy-abc123`, expect.objectContaining({ - headers: { 'Content-Type': 'application/json' }, + headers: { Accept: 'application/json' }, }) ); expect(result).toMatchObject({ userId: 'user-123', policyId: 'policy-456', - countryId: 'us', label: 'Test Policy', }); }); @@ -152,7 +173,7 @@ describe('ApiPolicyStore', () => { }); // When - const result = await store.findById('user-123', 'nonexistent'); + const result = await store.findById('nonexistent-id'); // Then expect(result).toBeNull(); @@ -163,40 +184,90 @@ describe('ApiPolicyStore', () => { (global.fetch as any).mockResolvedValue({ ok: false, status: 500, + text: async () => 'Internal Server Error', }); // When/Then - await expect(store.findById('user-123', 'policy-456')).rejects.toThrow( - 'Failed to fetch association' + await expect(store.findById('user-policy-abc123')).rejects.toThrow( + 'Failed to fetch policy association' ); }); }); describe('update', () => { - it('given update called then throws not supported error', async () => { - // Given & When & Then - await expect(store.update('sup-abc123', { label: 'Updated Label' })).rejects.toThrow( - 'Please ensure you are using localStorage mode' + it('given valid update then sends PATCH request', async () => { + // Given + const updatedResponse = { + ...mockApiResponse, + label: 'Updated Label', + updated_at: '2025-01-02T00:00:00Z', + }; + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => updatedResponse, + }); + + // When + const result = await store.update('user-policy-abc123', { label: 'Updated Label' }, 'user-123'); + + // Then + expect(fetch).toHaveBeenCalledWith( + `${API_V2_BASE_URL}/user-policies/user-policy-abc123?user_id=user-123`, + expect.objectContaining({ + method: 'PATCH', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + }) + ); + expect(result.label).toBe('Updated Label'); + }); + + it('given API error then throws error', async () => { + // Given + (global.fetch as any).mockResolvedValue({ + ok: false, + status: 500, + text: async () => 'Internal Server Error', + }); + + // When/Then + await expect(store.update('user-policy-abc123', { label: 'Updated Label' }, 'user-123')).rejects.toThrow( + 'Failed to update policy association' ); }); + }); - it('given update called then logs warning', async () => { + describe('delete', () => { + it('given valid userPolicyId then sends DELETE request', async () => { // Given - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + (global.fetch as any).mockResolvedValue({ + ok: true, + status: 204, + }); // When - try { - await store.update('sup-abc123', { label: 'Updated Label' }); - } catch { - // Expected to throw - } + await store.delete('user-policy-abc123', 'user-123'); // Then - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('API endpoint not yet implemented') + expect(fetch).toHaveBeenCalledWith( + `${API_V2_BASE_URL}/user-policies/user-policy-abc123?user_id=user-123`, + expect.objectContaining({ + method: 'DELETE', + }) ); + }); - consoleWarnSpy.mockRestore(); + it('given API error then throws error', async () => { + // Given + (global.fetch as any).mockResolvedValue({ + ok: false, + status: 500, + text: async () => 'Internal Server Error', + }); + + // When/Then + await expect(store.delete('user-policy-abc123', 'user-123')).rejects.toThrow( + 'Failed to delete policy association' + ); }); }); }); @@ -221,6 +292,14 @@ describe('LocalStoragePolicyStore', () => { isCreated: true, }; + const mockPolicyInputUK: Omit = { + userId: 'user-123', + policyId: 'policy-uk-001', + countryId: 'uk', + label: 'UK Policy', + isCreated: true, + }; + beforeEach(() => { // Mock localStorage mockLocalStorage = {}; @@ -252,6 +331,7 @@ describe('LocalStoragePolicyStore', () => { expect(result).toMatchObject({ userId: 'user-123', policyId: 'policy-456', + countryId: 'us', label: 'Test Policy 1', }); expect(result.id).toBeDefined(); @@ -272,6 +352,7 @@ describe('LocalStoragePolicyStore', () => { expect(second).toMatchObject({ userId: 'user-123', policyId: 'policy-456', + countryId: 'us', label: 'Test Policy 1', }); expect(second.id).toBeDefined(); @@ -281,18 +362,35 @@ describe('LocalStoragePolicyStore', () => { }); describe('findByUser', () => { - it('given user with policies then returns all user policies', async () => { + it('given user with policies then returns all user policies when no country filter', async () => { // Given await store.create(mockPolicyInput1); await store.create(mockPolicyInput2); + await store.create(mockPolicyInputUK); // When const result = await store.findByUser('user-123'); // Then - expect(result).toHaveLength(2); - expect(result[0].policyId).toBe('policy-456'); - expect(result[1].policyId).toBe('policy-789'); + expect(result).toHaveLength(3); + }); + + it('given user with policies then filters by country when country is provided', async () => { + // Given + await store.create(mockPolicyInput1); + await store.create(mockPolicyInput2); + await store.create(mockPolicyInputUK); + + // When + const usResult = await store.findByUser('user-123', 'us'); + const ukResult = await store.findByUser('user-123', 'uk'); + + // Then + expect(usResult).toHaveLength(2); + expect(usResult[0].countryId).toBe('us'); + expect(usResult[1].countryId).toBe('us'); + expect(ukResult).toHaveLength(1); + expect(ukResult[0].countryId).toBe('uk'); }); it('given user with no policies then returns empty array', async () => { @@ -305,12 +403,12 @@ describe('LocalStoragePolicyStore', () => { }); describe('findById', () => { - it('given existing policy then returns it', async () => { + it('given existing policy then returns it by userPolicyId', async () => { // Given - await store.create(mockPolicyInput1); + const created = await store.create(mockPolicyInput1); // When - const result = await store.findById('user-123', 'policy-456'); + const result = await store.findById(created.id!); // Then expect(result).toMatchObject({ @@ -319,9 +417,9 @@ describe('LocalStoragePolicyStore', () => { }); }); - it('given nonexistent policy then returns null', async () => { + it('given nonexistent userPolicyId then returns null', async () => { // When - const result = await store.findById('user-123', 'nonexistent'); + const result = await store.findById('sup-nonexistent'); // Then expect(result).toBeNull(); @@ -334,7 +432,7 @@ describe('LocalStoragePolicyStore', () => { const created = await store.create(mockPolicyInput1); // When - const result = await store.update(created.id!, { label: 'Updated Label' }); + const result = await store.update(created.id!, { label: 'Updated Label' }, 'user-123'); // Then expect(result.label).toBe('Updated Label'); @@ -347,7 +445,7 @@ describe('LocalStoragePolicyStore', () => { // Given - no policy created // When & Then - await expect(store.update('sup-nonexistent', { label: 'Updated Label' })).rejects.toThrow( + await expect(store.update('sup-nonexistent', { label: 'Updated Label' }, 'user-123')).rejects.toThrow( 'UserPolicy with id sup-nonexistent not found' ); }); @@ -358,7 +456,7 @@ describe('LocalStoragePolicyStore', () => { const beforeUpdate = new Date().toISOString(); // When - const result = await store.update(created.id!, { label: 'Updated Label' }); + const result = await store.update(created.id!, { label: 'Updated Label' }, 'user-123'); // Then expect(result.updatedAt).toBeDefined(); @@ -370,24 +468,24 @@ describe('LocalStoragePolicyStore', () => { const created = await store.create(mockPolicyInput1); // When - await store.update(created.id!, { label: 'Updated Label' }); + await store.update(created.id!, { label: 'Updated Label' }, 'user-123'); // Then - const persisted = await store.findById(mockPolicyInput1.userId, mockPolicyInput1.policyId); + const persisted = await store.findById(created.id!); expect(persisted?.label).toBe('Updated Label'); }); it('given multiple policies then updates correct one by ID', async () => { // Given const created1 = await store.create(mockPolicyInput1); - await store.create(mockPolicyInput2); + const created2 = await store.create(mockPolicyInput2); // When - await store.update(created1.id!, { label: 'Updated Label' }); + await store.update(created1.id!, { label: 'Updated Label' }, 'user-123'); // Then - const updated = await store.findById(mockPolicyInput1.userId, mockPolicyInput1.policyId); - const unchanged = await store.findById(mockPolicyInput2.userId, mockPolicyInput2.policyId); + const updated = await store.findById(created1.id!); + const unchanged = await store.findById(created2.id!); expect(updated?.label).toBe('Updated Label'); expect(unchanged?.label).toBe(mockPolicyInput2.label); }); @@ -397,11 +495,11 @@ describe('LocalStoragePolicyStore', () => { const created = await store.create(mockPolicyInput1); // When - const result = await store.update(created.id!, { label: 'Updated Label' }); + const result = await store.update(created.id!, { label: 'Updated Label' }, 'user-123'); // Then expect(result.label).toBe('Updated Label'); - expect(result.countryId).toBe(mockPolicyInput1.countryId); // unchanged + expect(result.policyId).toBe(mockPolicyInput1.policyId); // unchanged }); }); diff --git a/app/src/tests/unit/hooks/useUserReports.test.tsx b/app/src/tests/unit/hooks/useUserReports.test.tsx index 9dd924c35..9a1cb98f9 100644 --- a/app/src/tests/unit/hooks/useUserReports.test.tsx +++ b/app/src/tests/unit/hooks/useUserReports.test.tsx @@ -34,14 +34,14 @@ import { mockHouseholdMetadata, mockMetadataInitialState, mockPolicy1, - mockPolicyMetadata1, - mockPolicyMetadata2, mockSimulation1, mockSimulationMetadata1, mockSimulationMetadata2, mockUserHouseholds, mockUserPolicies, mockUserSimulations, + mockV2PolicyResponse1, + mockV2PolicyResponse2, TEST_HOUSEHOLD_ID, TEST_POLICY_ID_1, TEST_POLICY_ID_2, @@ -154,12 +154,12 @@ describe('useUserReports', () => { return Promise.reject(new Error(ERROR_MESSAGES.SIMULATION_NOT_FOUND(id))); }); - vi.spyOn(policyApi, 'fetchPolicyById').mockImplementation((_country, id) => { + vi.spyOn(policyApi, 'fetchPolicyById').mockImplementation((id: string) => { if (id === TEST_POLICY_ID_1) { - return Promise.resolve(mockPolicyMetadata1); + return Promise.resolve(mockV2PolicyResponse1); } if (id === TEST_POLICY_ID_2) { - return Promise.resolve(mockPolicyMetadata2); + return Promise.resolve(mockV2PolicyResponse2); } return Promise.reject(new Error(ERROR_MESSAGES.POLICY_NOT_FOUND(id))); }); @@ -582,12 +582,12 @@ describe('useUserReportById', () => { } return Promise.reject(new Error(ERROR_MESSAGES.SIMULATION_NOT_FOUND(id))); }); - vi.spyOn(policyApi, 'fetchPolicyById').mockImplementation((_country, id) => { + vi.spyOn(policyApi, 'fetchPolicyById').mockImplementation((id: string) => { if (id === TEST_POLICY_ID_1) { - return Promise.resolve(mockPolicyMetadata1); + return Promise.resolve(mockV2PolicyResponse1); } if (id === TEST_POLICY_ID_2) { - return Promise.resolve(mockPolicyMetadata2); + return Promise.resolve(mockV2PolicyResponse2); } return Promise.reject(new Error(ERROR_MESSAGES.POLICY_NOT_FOUND(id))); }); diff --git a/app/src/tests/unit/libs/userIdentity.test.ts b/app/src/tests/unit/libs/userIdentity.test.ts new file mode 100644 index 000000000..68f1abcba --- /dev/null +++ b/app/src/tests/unit/libs/userIdentity.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { clearUserId, getUserId, STORAGE_KEYS } from '@/libs/userIdentity'; + +describe('userIdentity', () => { + beforeEach(() => { + // Clear localStorage before each test + localStorage.clear(); + + // Mock crypto.randomUUID + vi.spyOn(crypto, 'randomUUID').mockReturnValue('test-uuid-1234-5678-90ab-cdef12345678'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); + }); + + describe('getUserId', () => { + test('given no existing user ID then generates and stores new UUID', () => { + // When + const userId = getUserId(); + + // Then + expect(userId).toBe('test-uuid-1234-5678-90ab-cdef12345678'); + expect(localStorage.getItem(STORAGE_KEYS.USER_ID)).toBe( + 'test-uuid-1234-5678-90ab-cdef12345678' + ); + expect(crypto.randomUUID).toHaveBeenCalled(); + }); + + test('given existing user ID then returns stored ID without generating new one', () => { + // Given + localStorage.setItem(STORAGE_KEYS.USER_ID, 'existing-user-id-12345'); + + // When + const userId = getUserId(); + + // Then + expect(userId).toBe('existing-user-id-12345'); + expect(crypto.randomUUID).not.toHaveBeenCalled(); + }); + + test('given multiple calls then returns same user ID', () => { + // When + const userId1 = getUserId(); + const userId2 = getUserId(); + const userId3 = getUserId(); + + // Then + expect(userId1).toBe(userId2); + expect(userId2).toBe(userId3); + expect(crypto.randomUUID).toHaveBeenCalledTimes(1); + }); + }); + + describe('clearUserId', () => { + test('given user ID exists then removes it from localStorage', () => { + // Given + localStorage.setItem(STORAGE_KEYS.USER_ID, 'existing-user-id'); + + // When + clearUserId(); + + // Then + expect(localStorage.getItem(STORAGE_KEYS.USER_ID)).toBeNull(); + }); + + test('given user ID cleared then next getUserId generates new ID', () => { + // Given + localStorage.setItem(STORAGE_KEYS.USER_ID, 'old-user-id'); + + // When + clearUserId(); + const newUserId = getUserId(); + + // Then + expect(newUserId).toBe('test-uuid-1234-5678-90ab-cdef12345678'); + expect(newUserId).not.toBe('old-user-id'); + }); + }); + + describe('STORAGE_KEYS', () => { + test('given STORAGE_KEYS then contains expected key names', () => { + // Then + expect(STORAGE_KEYS.USER_ID).toBe('policyengine_user_id'); + }); + }); +}); diff --git a/app/src/tests/unit/utils/countParameterChanges.test.ts b/app/src/tests/unit/utils/countParameterChanges.test.ts index 47eeef65a..48db59472 100644 --- a/app/src/tests/unit/utils/countParameterChanges.test.ts +++ b/app/src/tests/unit/utils/countParameterChanges.test.ts @@ -1,71 +1,67 @@ import { describe, expect, test } from 'vitest'; -import { - mockPolicyWithEmptyParameter, - mockPolicyWithMultipleParameters, - mockPolicyWithNoJson, - mockPolicyWithNullParameter, - mockPolicyWithOneParameterMultipleRanges, - mockPolicyWithOneParameterOneRange, -} from '@/tests/fixtures/utils/countParameterChangesMocks'; -import { countParameterChanges } from '@/utils/countParameterChanges'; +import { countPolicyModifications } from '@/utils/countParameterChanges'; -describe('countParameterChanges', () => { +describe('countPolicyModifications', () => { test('given undefined policy then returns 0', () => { - // Given - const policy = undefined; - - // When - const result = countParameterChanges(policy); - - // Then - expect(result).toBe(0); + expect(countPolicyModifications(undefined)).toBe(0); }); - test('given policy with no policy_json then returns 0', () => { - // When - const result = countParameterChanges(mockPolicyWithNoJson); - - // Then - expect(result).toBe(0); + test('given null policy then returns 0', () => { + expect(countPolicyModifications(null)).toBe(0); }); - test('given policy with one parameter and one date range then returns 1', () => { - // When - const result = countParameterChanges(mockPolicyWithOneParameterOneRange); - - // Then - expect(result).toBe(1); + test('given policy with no parameters then returns 0', () => { + expect(countPolicyModifications({ parameters: undefined })).toBe(0); }); - test('given policy with one parameter and multiple date ranges then returns count of date ranges', () => { - // When - const result = countParameterChanges(mockPolicyWithOneParameterMultipleRanges); - - // Then - expect(result).toBe(3); + test('given policy with empty parameters then returns 0', () => { + expect(countPolicyModifications({ parameters: [] })).toBe(0); }); - test('given policy with multiple parameters then returns sum of all date ranges', () => { - // When - const result = countParameterChanges(mockPolicyWithMultipleParameters); - - // Then - expect(result).toBe(6); // 2 + 3 + 1 + test('given policy with one parameter and one value interval then returns 1', () => { + const policy = { + parameters: [ + { + name: 'tax_rate', + values: [{ startDate: '2024-01-01', endDate: '2024-12-31', value: 0.25 }], + }, + ], + }; + expect(countPolicyModifications(policy)).toBe(1); }); - test('given policy with parameter having null values then handles gracefully', () => { - // When - const result = countParameterChanges(mockPolicyWithNullParameter); - - // Then - expect(result).toBe(0); + test('given policy with one parameter and multiple value intervals then returns count', () => { + const policy = { + parameters: [ + { + name: 'tax_rate', + values: [ + { startDate: '2024-01-01', endDate: '2024-12-31', value: 0.25 }, + { startDate: '2025-01-01', endDate: '2025-12-31', value: 0.27 }, + { startDate: '2026-01-01', endDate: '2026-12-31', value: 0.3 }, + ], + }, + ], + }; + expect(countPolicyModifications(policy)).toBe(3); }); - test('given policy with empty parameter object then returns 0', () => { - // When - const result = countParameterChanges(mockPolicyWithEmptyParameter); - - // Then - expect(result).toBe(0); + test('given policy with multiple parameters then returns sum of all value intervals', () => { + const policy = { + parameters: [ + { + name: 'tax_rate', + values: [ + { startDate: '2024-01-01', endDate: '2024-12-31', value: 0.25 }, + { startDate: '2025-01-01', endDate: '2025-12-31', value: 0.27 }, + ], + }, + { + name: 'benefit_amount', + values: [{ startDate: '2024-01-01', endDate: '2024-12-31', value: 1000 }], + }, + ], + }; + expect(countPolicyModifications(policy)).toBe(3); }); }); diff --git a/app/src/types/ingredients/Policy.ts b/app/src/types/ingredients/Policy.ts index 08e9a2950..93fbd2b48 100644 --- a/app/src/types/ingredients/Policy.ts +++ b/app/src/types/ingredients/Policy.ts @@ -7,6 +7,7 @@ import { Parameter } from '@/types/subIngredients/parameter'; export interface Policy { id?: string; countryId?: (typeof countryIds)[number]; + taxBenefitModelId?: string; // UUID of the tax benefit model (v2 API) apiVersion?: string; parameters?: Parameter[]; label?: string | null; diff --git a/app/src/types/ingredients/UserPolicy.ts b/app/src/types/ingredients/UserPolicy.ts index a3ef74192..5e60bcfbc 100644 --- a/app/src/types/ingredients/UserPolicy.ts +++ b/app/src/types/ingredients/UserPolicy.ts @@ -1,4 +1,4 @@ -import { countryIds } from '@/libs/countries'; +import { CountryId } from '@/libs/countries'; /** * UserPolicy type containing mutable user-specific data @@ -7,7 +7,7 @@ export interface UserPolicy { id?: string; userId: string; policyId: string; - countryId: (typeof countryIds)[number]; + countryId: CountryId; label?: string; createdAt?: string; updatedAt?: string; diff --git a/app/src/types/metadata/policyMetadata.ts b/app/src/types/metadata/policyMetadata.ts index 06f0ee749..5ed2c0fe8 100644 --- a/app/src/types/metadata/policyMetadata.ts +++ b/app/src/types/metadata/policyMetadata.ts @@ -1,14 +1,3 @@ -import { countryIds } from '@/libs/countries'; - -export interface PolicyMetadata { - id: string; - country_id: (typeof countryIds)[number]; - label?: string; - api_version: string; - policy_json: PolicyMetadataParams; - policy_hash: string; -} - export interface PolicyMetadataParams { [param: string]: PolicyMetadataParamValues; } diff --git a/app/src/types/metadata/userPolicyMetadata.ts b/app/src/types/metadata/userPolicyMetadata.ts deleted file mode 100644 index ec0d1a9c3..000000000 --- a/app/src/types/metadata/userPolicyMetadata.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { countryIds } from '@/libs/countries'; - -/** - * API response format for user policy associations - * Uses snake_case to match API conventions - */ -export interface UserPolicyMetadata { - user_id: string; - policy_id: string; - country_id: (typeof countryIds)[number]; - label?: string | null; - created_at?: string; - updated_at?: string; -} - -/** - * API creation payload format for user policy associations - * Uses snake_case to match API conventions - */ -export interface UserPolicyCreationMetadata { - user_id: string; - policy_id: string; - country_id: (typeof countryIds)[number]; - label?: string | null; - created_at?: string; - updated_at?: string; -} diff --git a/app/src/types/payloads/PolicyCreationPayload.ts b/app/src/types/payloads/PolicyCreationPayload.ts deleted file mode 100644 index eacdb7aa4..000000000 --- a/app/src/types/payloads/PolicyCreationPayload.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { PolicyMetadataParams } from '@/types/metadata/policyMetadata'; - -/** - * Payload format for creating a policy via the API - */ -export interface PolicyCreationPayload { - label?: string; - data: PolicyMetadataParams; -} diff --git a/app/src/types/payloads/UserPolicyCreationPayload.ts b/app/src/types/payloads/UserPolicyCreationPayload.ts deleted file mode 100644 index a9c35a353..000000000 --- a/app/src/types/payloads/UserPolicyCreationPayload.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { countryIds } from '@/libs/countries'; - -/** - * Payload format for creating a user-policy association via the API - * Note: This endpoint doesn't exist yet. Currently uses the same format as UserPolicyMetadata. - * In the future, we may create a separate type if the creation payload differs from the response format. - */ -export interface UserPolicyCreationPayload { - user_id: string; - policy_id: string; - country_id: (typeof countryIds)[number]; - label?: string | null; - created_at?: string; - updated_at?: string; -} diff --git a/app/src/types/payloads/index.ts b/app/src/types/payloads/index.ts index 6dbf3f5bc..bd10898fc 100644 --- a/app/src/types/payloads/index.ts +++ b/app/src/types/payloads/index.ts @@ -1,9 +1,7 @@ -export type { PolicyCreationPayload } from './PolicyCreationPayload'; export type { SimulationCreationPayload } from './SimulationCreationPayload'; export type { SimulationSetOutputPayload } from './SimulationSetOutputPayload'; export type { HouseholdCreationPayload } from './HouseholdCreationPayload'; export type { ReportCreationPayload } from './ReportCreationPayload'; export type { ReportSetOutputPayload } from './ReportSetOutputPayload'; -export type { UserPolicyCreationPayload } from './UserPolicyCreationPayload'; export type { UserReportCreationPayload } from './UserReportCreationPayload'; export type { UserSimulationCreationPayload } from './UserSimulationCreationPayload'; diff --git a/app/src/utils/countParameterChanges.ts b/app/src/utils/countParameterChanges.ts index e3cb1089b..95d845bc5 100644 --- a/app/src/utils/countParameterChanges.ts +++ b/app/src/utils/countParameterChanges.ts @@ -1,34 +1,10 @@ import { Policy } from '@/types/ingredients/Policy'; -import { PolicyMetadata } from '@/types/metadata/policyMetadata'; /** - * Counts the number of parameter changes in a policy metadata object. - * Each parameter can have multiple date ranges, and each date range counts as one change. - * - * @param policy - The policy metadata to count parameter changes for - * @returns The total number of parameter changes across all parameters - */ -export const countParameterChanges = (policy: PolicyMetadata | undefined): number => { - if (!policy?.policy_json) { - return 0; - } - - let count = 0; - - for (const paramName in policy.policy_json) { - if (policy.policy_json[paramName]) { - count += Object.keys(policy.policy_json[paramName]).length; - } - } - - return count; -}; - -/** - * Counts the number of value intervals (parameter modifications) in a Redux Policy object. + * Counts the number of value intervals (parameter modifications) in a Policy object. * Each value interval in each parameter counts as one modification. * - * @param policy - The Redux policy to count modifications for + * @param policy - The policy to count modifications for * @returns The total number of value intervals across all parameters */ export const countPolicyModifications = (policy: Policy | undefined | null): number => {