diff --git a/app/src/frames/population/HouseholdBuilderFrame.tsx b/app/src/frames/population/HouseholdBuilderFrame.tsx index 31e55cfc..0bca1744 100644 --- a/app/src/frames/population/HouseholdBuilderFrame.tsx +++ b/app/src/frames/population/HouseholdBuilderFrame.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { shallowEqual, useDispatch, useSelector } from 'react-redux'; import { Divider, Group, @@ -293,7 +293,7 @@ export default function HouseholdBuilderFrame({ } }); return options; - }); + }, shallowEqual); const handleSubmit = async () => { // Sync final household to Redux before submit diff --git a/app/src/tests/CLAUDE.md b/app/src/tests/CLAUDE.md index 45c56676..de0da615 100644 --- a/app/src/tests/CLAUDE.md +++ b/app/src/tests/CLAUDE.md @@ -12,9 +12,16 @@ import { render, screen, userEvent } from '@test-utils'; ``` -2. **File location**: Place tests in `src/tests/` mirroring the source structure - - `src/components/Button.tsx` → `src/tests/components/Button.test.tsx` - - `src/hooks/useAuth.ts` → `src/tests/hooks/useAuth.test.ts` +2. **File location**: Place tests in `src/tests/TYPE/` mirroring the source structure, where TYPE is: + - `unit` for unit tests (most common) + - `integration` for integration tests + - Other types as needed + + Examples: + - `src/components/Button.tsx` → `src/tests/unit/components/Button.test.tsx` + - `src/hooks/useAuth.ts` → `src/tests/unit/hooks/useAuth.test.ts` + - `src/adapters/HouseholdAdapter.ts` → `src/tests/unit/adapters/HouseholdAdapter.test.ts` + - API integration test → `src/tests/integration/api/PolicyEngine.test.ts` 3. **Test naming**: Use Given-When-Then pattern for clear, descriptive test names ```typescript @@ -74,6 +81,63 @@ └── hooks/ └── useAuthMocks.ts ``` + + **Fixture best practices**: + - **Use descriptive constants**: Define constants that explain their purpose + ```typescript + // Good - Clear what each ID represents + export const EXISTING_HOUSEHOLD_ID = '12345'; + export const NON_EXISTENT_HOUSEHOLD_ID = '99999'; + export const NEW_HOUSEHOLD_ID = 'household-123'; + + // Bad - Magic numbers/strings in tests + const householdId = '12345'; // What does this represent? + ``` + + - **Group related constants**: Organize constants by their domain + ```typescript + export const TEST_COUNTRIES = { + US: 'us', + UK: 'uk', + CA: 'ca', + } as const; + + export const HTTP_STATUS = { + OK: 200, + NOT_FOUND: 404, + INTERNAL_SERVER_ERROR: 500, + } as const; + ``` + + - **Create helper functions for common patterns**: + ```typescript + export const mockSuccessResponse = (data: any) => ({ + ok: true, + status: HTTP_STATUS.OK, + json: vi.fn().mockResolvedValue(data), + }); + + export const mockErrorResponse = (status: number) => ({ + ok: false, + status, + statusText: status === HTTP_STATUS.NOT_FOUND ? 'Not Found' : 'Error', + }); + ``` + + - **Match implementation error messages**: Keep error constants in sync + ```typescript + export const ERROR_MESSAGES = { + FETCH_FAILED: (id: string) => `Failed to fetch household ${id}`, + CREATE_FAILED: 'Failed to create household', + } as const; + ``` + + - **Provide variants for different test scenarios**: + ```typescript + export const mockHouseholdPayload = { /* base payload */ }; + export const mockHouseholdPayloadUK = { ...mockHouseholdPayload, country_id: 'uk' }; + export const mockLargeHouseholdPayload = { /* complex payload with all fields */ }; + ``` 7. **Always mock Plotly**: Add to test file or setup ```typescript @@ -85,11 +149,26 @@ - Mock API calls at module level - Reset mocks with `vi.clearAllMocks()` in `beforeEach` - Import mocks from fixtures: `import { mockPolicyData } from '@/tests/fixtures/api/policyMocks';` + - Keep test data in fixtures, not inline in tests (improves maintainability) 9. **Async patterns**: Use `userEvent.setup()` for interactions, `waitFor` for async updates 10. **Coverage targets**: Aim for 80% overall, 90% for critical paths (adapters, utils, hooks) +11. **Test readability principles**: + - **Use constants over magic values**: Makes tests self-documenting + ```typescript + // Good - Intent is clear + const result = await fetchHouseholdById(TEST_COUNTRIES.US, EXISTING_HOUSEHOLD_ID); + + // Bad - What do these values represent? + const result = await fetchHouseholdById('us', '12345'); + ``` + + - **One assertion per test when possible**: Makes failures easier to diagnose + - **Test the "what", not the "how"**: Focus on behavior, not implementation + - **Use fixture data that represents real scenarios**: Don't use "foo", "bar", "test123" + ## What to test - User interactions and state changes - Props rendering correctly diff --git a/app/src/tests/fixtures/adapters/HouseholdAdapterMocks.ts b/app/src/tests/fixtures/adapters/HouseholdAdapterMocks.ts new file mode 100644 index 00000000..a042faf4 --- /dev/null +++ b/app/src/tests/fixtures/adapters/HouseholdAdapterMocks.ts @@ -0,0 +1,153 @@ +import { HouseholdData } from '@/types/ingredients/Household'; +import { HouseholdMetadata } from '@/types/metadata/householdMetadata'; + +export const mockEntityMetadata = { + person: { + key: 'person', + plural: 'people', + label: 'Person', + }, + tax_unit: { + key: 'tax_unit', + plural: 'tax_units', + label: 'Tax unit', + }, + marital_unit: { + key: 'marital_unit', + plural: 'marital_units', + label: 'Marital unit', + }, + household: { + key: 'household', + plural: 'households', + label: 'Household', + }, + spm_unit: { + key: 'spm_unit', + plural: 'spm_units', + label: 'SPM unit', + }, +}; + +export const mockHouseholdMetadata: HouseholdMetadata = { + id: '12345', + country_id: 'us', + household_json: { + people: { + person1: { + age: { 2024: 30 }, + employment_income: { 2024: 50000 }, + }, + person2: { + age: { 2024: 28 }, + employment_income: { 2024: 45000 }, + }, + }, + tax_units: { + tax_unit1: { + members: ['person1', 'person2'], + }, + }, + marital_units: { + marital_unit1: { + members: ['person1', 'person2'], + }, + }, + spm_units: { + spm_unit1: { + members: ['person1', 'person2'], + }, + }, + households: { + household1: { + members: ['person1', 'person2'], + }, + }, + families: { + family1: { + members: ['person1', 'person2'], + }, + }, + }, + api_version: 'v1', + household_hash: '', +}; + +export const mockHouseholdMetadataWithUnknownEntity: HouseholdMetadata = { + id: '67890', + country_id: 'uk', + household_json: { + people: { + person1: { + age: { 2024: 40 }, + }, + }, + // @ts-expect-error + unknown_entity: { + entity1: { + some_property: 'value', + }, + }, + }, +}; + +export const mockHouseholdData: HouseholdData = { + people: { + person1: { + age: { 2024: 30 }, + employment_income: { 2024: 50000 }, + }, + person2: { + age: { 2024: 28 }, + employment_income: { 2024: 45000 }, + }, + }, + taxUnits: { + tax_unit1: { + members: ['person1', 'person2'], + head: 'person1', + }, + }, + maritalUnits: { + marital_unit1: { + members: ['person1', 'person2'], + }, + }, +}; + +export const mockHouseholdDataWithMultipleEntities: HouseholdData = { + people: { + person1: { age: { 2024: 25 } }, + person2: { age: { 2024: 23 } }, + person3: { age: { 2024: 5 } }, + }, + taxUnits: { + tax_unit1: { + members: ['person1', 'person2', 'person3'], + head: 'person1', + }, + }, + maritalUnits: { + marital_unit1: { + members: ['person1', 'person2'], + }, + }, + spmUnits: { + spm_unit1: { + members: ['person1', 'person2', 'person3'], + }, + }, +}; + +export const mockEmptyHouseholdData: HouseholdData = { + people: {}, +}; + +export const mockHouseholdDataWithUnknownEntity: HouseholdData = { + people: { + person1: { age: { 2024: 30 } }, + }, + customEntity: { + entity1: { custom_field: 'value' }, + }, +} as any; diff --git a/app/src/tests/fixtures/api/householdAssociationMocks.ts b/app/src/tests/fixtures/api/householdAssociationMocks.ts new file mode 100644 index 00000000..7bfce969 --- /dev/null +++ b/app/src/tests/fixtures/api/householdAssociationMocks.ts @@ -0,0 +1,77 @@ +import { UserHouseholdPopulation } from '@/types/ingredients/UserPopulation'; + +export const mockUserHouseholdPopulation: UserHouseholdPopulation = { + type: 'household', + id: 'household-123', + householdId: 'household-123', + userId: 'user-456', + label: 'My Test Household', + createdAt: '2024-01-15T10:00:00Z', + updatedAt: '2024-01-15T10:00:00Z', + isCreated: true, +} as any; + +export const mockUserHouseholdPopulationList: UserHouseholdPopulation[] = [ + { + type: 'household', + id: 'household-1', + householdId: 'household-1', + userId: 'user-456', + label: 'First Household', + createdAt: '2024-01-10T10:00:00Z', + updatedAt: '2024-01-10T10:00:00Z', + isCreated: true, + } as any, + { + type: 'household', + id: 'household-2', + householdId: 'household-2', + userId: 'user-456', + label: 'Second Household', + createdAt: '2024-01-12T10:00:00Z', + updatedAt: '2024-01-12T10:00:00Z', + isCreated: true, + } as any, +]; + +export const mockApiResponse = { + id: 'household-123', + household_id: 'household-123', + user_id: 'user-456', + user_label: 'My Test Household', + country_id: 'us', + created_at: '2024-01-15T10:00:00Z', + updated_at: '2024-01-15T10:00:00Z', + is_default: false, +}; + +export const mockApiResponseList = [ + { + id: 'household-1', + household_id: 'household-1', + user_id: 'user-456', + user_label: 'First Household', + country_id: 'us', + created_at: '2024-01-10T10:00:00Z', + updated_at: '2024-01-10T10:00:00Z', + is_default: true, + }, + { + id: 'household-2', + household_id: 'household-2', + user_id: 'user-456', + user_label: 'Second Household', + country_id: 'uk', + created_at: '2024-01-12T10:00:00Z', + updated_at: '2024-01-12T10:00:00Z', + is_default: false, + }, +]; + +export const mockCreationPayload = { + household_id: 'household-123', + user_id: 'user-456', + user_label: 'My Test Household', + country_id: 'us', + is_default: false, +}; diff --git a/app/src/tests/fixtures/api/householdMocks.ts b/app/src/tests/fixtures/api/householdMocks.ts new file mode 100644 index 00000000..33b0153f --- /dev/null +++ b/app/src/tests/fixtures/api/householdMocks.ts @@ -0,0 +1,127 @@ +import { vi } from 'vitest'; +import { HouseholdMetadata } from '@/types/metadata/householdMetadata'; +import { HouseholdCreationPayload } from '@/types/payloads'; + +// Test household IDs - descriptive names for clarity +export const EXISTING_HOUSEHOLD_ID = '12345'; +export const NON_EXISTENT_HOUSEHOLD_ID = '99999'; +export const NEW_HOUSEHOLD_ID = 'household-123'; + +// Country codes used in tests +export const TEST_COUNTRIES = { + US: 'us', + UK: 'uk', + CA: 'ca', +} as const; + +// HTTP status codes +export const HTTP_STATUS = { + OK: 200, + BAD_REQUEST: 400, + NOT_FOUND: 404, + INTERNAL_SERVER_ERROR: 500, +} as const; + +// Error messages that match the actual implementation +export const ERROR_MESSAGES = { + FETCH_HOUSEHOLD_FAILED: (id: string) => `Failed to fetch household ${id}`, + CREATE_HOUSEHOLD_FAILED: 'Failed to create household', + NETWORK_ERROR: 'Network error', + FAILED_TO_FETCH: 'Failed to fetch', +} as const; + +export const mockHouseholdMetadata: HouseholdMetadata = { + id: '12345', + country_id: 'us', + household_json: { + people: { + person1: { + age: { 2024: 30 }, + employment_income: { 2024: 50000 }, + }, + person2: { + age: { 2024: 28 }, + employment_income: { 2024: 45000 }, + }, + }, + families: {}, + tax_units: { + tax_unit1: { + members: ['person1', 'person2'], + }, + }, + spm_units: {}, + households: { + household1: { + members: ['person1', 'person2'], + }, + }, + marital_units: {}, + }, + api_version: 'v1', + household_hash: '', +}; + +export const mockHouseholdCreationPayload: HouseholdCreationPayload = { + country_id: 'us', + data: { + people: { + person1: { + age: { 2024: 25 }, + }, + }, + families: {}, + tax_units: {}, + spm_units: {}, + households: {}, + marital_units: {}, + }, +}; + +// UK variant for testing different countries +export const mockHouseholdCreationPayloadUK: HouseholdCreationPayload = { + ...mockHouseholdCreationPayload, + country_id: 'uk', +}; + +// Large household payload for testing complex data handling +export const mockLargeHouseholdPayload: HouseholdCreationPayload = { + country_id: 'us', + data: { + people: { + person1: { age: { 2024: 30 }, employment_income: { 2024: 50000 } }, + person2: { age: { 2024: 28 }, employment_income: { 2024: 45000 } }, + person3: { age: { 2024: 5 } }, + person4: { age: { 2024: 3 } }, + }, + families: {}, + spm_units: {}, + households: {}, + marital_units: {}, + tax_units: { + unit1: { members: ['person1', 'person2', 'person3', 'person4'] }, + }, + }, +}; + +export const mockCreateHouseholdResponse = { + result: { + household_id: NEW_HOUSEHOLD_ID, + }, +}; + +// Mock fetch responses +export const mockSuccessResponse = (data: any) => ({ + ok: true, + status: HTTP_STATUS.OK, + json: vi.fn().mockResolvedValue(data), +}); + +export const mockErrorResponse = (status: number) => ({ + ok: false, + status, + statusText: status === HTTP_STATUS.NOT_FOUND ? 'Not Found' : 'Error', +}); + +export const mockNetworkError = new Error(ERROR_MESSAGES.NETWORK_ERROR); +export const mockFetchError = new Error(ERROR_MESSAGES.FAILED_TO_FETCH); diff --git a/app/src/tests/fixtures/frames/populationMocks.ts b/app/src/tests/fixtures/frames/populationMocks.ts new file mode 100644 index 00000000..ff95a7d3 --- /dev/null +++ b/app/src/tests/fixtures/frames/populationMocks.ts @@ -0,0 +1,380 @@ +import { vi } from 'vitest'; +import { RootState } from '@/store'; +import { FlowComponentProps } from '@/types/flow'; +import { Geography } from '@/types/ingredients/Geography'; +import { Household } from '@/types/ingredients/Household'; +import { UserGeographicAssociation } from '@/types/userIngredientAssociations'; + +// Test IDs and labels +export const TEST_USER_ID = 'test-user-123'; +export const TEST_HOUSEHOLD_ID = 'household-456'; +export const TEST_POPULATION_LABEL = 'Test Population 2024'; +export const EMPTY_LABEL = ''; +export const LONG_LABEL = 'A'.repeat(101); // Over 100 char limit + +// Test text constants for assertions +export const UI_TEXT = { + // Common + CONTINUE_BUTTON: /Continue/i, + BACK_BUTTON: /Back/i, + + // GeographicConfirmationFrame + CONFIRM_GEOGRAPHIC_TITLE: 'Confirm Geographic Selection', + CREATE_ASSOCIATION_BUTTON: /Create Geographic Association/i, + SCOPE_NATIONAL: 'National', + SCOPE_STATE: 'State', + SCOPE_CONSTITUENCY: 'Constituency', + COUNTRY_US: 'United States', + COUNTRY_UK: 'United Kingdom', + STATE_CALIFORNIA: 'California', + CONSTITUENCY_LONDON: 'London', + + // HouseholdBuilderFrame + BUILD_HOUSEHOLD_TITLE: 'Build Your Household', + CREATE_HOUSEHOLD_BUTTON: /Create household/i, + TAX_YEAR_LABEL: 'Tax Year', + MARITAL_STATUS_LABEL: 'Marital Status', + NUM_CHILDREN_LABEL: 'Number of Children', + YOU_LABEL: 'You', + YOUR_PARTNER_LABEL: 'Your Partner', + CHILD_LABEL: (n: number) => `Child ${n}`, + MARITAL_SINGLE: 'Single', + MARITAL_MARRIED: 'Married', + ERROR_LOAD_DATA: 'Failed to Load Required Data', + ERROR_LOAD_MESSAGE: /Unable to load household configuration data/, + + // SelectGeographicScopeFrame + CHOOSE_SCOPE_TITLE: 'Choose Geographic Scope', + SCOPE_NATIONAL_LABEL: 'National', + SCOPE_STATE_LABEL: 'State', + SCOPE_HOUSEHOLD_LABEL: 'Custom Household', + SELECT_STATE_PLACEHOLDER: 'Select a state', + SELECT_UK_COUNTRY_PLACEHOLDER: 'Select a UK country', + SELECT_CONSTITUENCY_PLACEHOLDER: 'Select a constituency', + COUNTRY_ENGLAND: 'England', + COUNTRY_SCOTLAND: 'Scotland', + STATE_NEW_YORK: 'New York', + STATE_TEXAS: 'Texas', + CONSTITUENCY_MANCHESTER: 'Manchester', + + // SetPopulationLabelFrame + NAME_POPULATION_TITLE: 'Name Your Population', + POPULATION_LABEL: 'Population Label', + LABEL_PLACEHOLDER: 'e.g., My Family 2024, California Low Income, UK National Average', + LABEL_DESCRIPTION: 'Give your population a descriptive name to help identify it later.', + LABEL_HELP_TEXT: 'This label will help you identify this population when creating simulations.', + ERROR_EMPTY_LABEL: 'Please enter a label for your population', + ERROR_LONG_LABEL: 'Label must be less than 100 characters', + DEFAULT_NATIONAL_LABEL: 'National Population', + DEFAULT_HOUSEHOLD_LABEL: 'Custom Household', + DEFAULT_STATE_LABEL: (state: string) => `${state} Population`, +} as const; + +// Error messages +export const ERROR_MESSAGES = { + FAILED_CREATE_ASSOCIATION: 'Failed to create geographic association:', + FAILED_CREATE_HOUSEHOLD: 'Failed to create household:', + STATE_NO_REGION: 'State selected but no region chosen', + VALIDATION_FAILED: 'Household validation failed:', + MISSING_REQUIRED_FIELDS: 'Missing required fields', +} as const; + +// Input field placeholders +export const PLACEHOLDERS = { + AGE: 'Age', + EMPLOYMENT_INCOME: 'Employment Income', + STATE_CODE: 'State', +} as const; + +// Numeric test values +export const TEST_VALUES = { + DEFAULT_AGE: 30, + DEFAULT_INCOME: 50000, + UPDATED_AGE: 35, + UPDATED_INCOME: 75000, + PARTNER_AGE: 28, + PARTNER_INCOME: 45000, + CHILD_DEFAULT_AGE: 10, + MIN_ADULT_AGE: 18, + MAX_ADULT_AGE: 120, + MIN_CHILD_AGE: 0, + MAX_CHILD_AGE: 17, + LABEL_MAX_LENGTH: 100, +} as const; + +// Geographic constants +export const GEOGRAPHIC_SCOPES = { + NATIONAL: 'national', + STATE: 'state', + HOUSEHOLD: 'household', +} as const; + +export const TEST_COUNTRIES = { + US: 'us', + UK: 'uk', +} as const; + +export const TEST_REGIONS = { + US_CALIFORNIA: 'state/ca', + US_NEW_YORK: 'state/ny', + UK_LONDON: 'constituency/london', + UK_MANCHESTER: 'constituency/manchester', +} as const; + +// Mock geography objects +export const mockNationalGeography: Geography = { + id: TEST_COUNTRIES.US, + countryId: TEST_COUNTRIES.US as any, + scope: 'national', + geographyId: TEST_COUNTRIES.US, +}; + +export const mockStateGeography: Geography = { + id: `${TEST_COUNTRIES.US}-ca`, + countryId: TEST_COUNTRIES.US as any, + scope: 'subnational', + geographyId: 'ca', +}; + +// Mock household - using a function to return a fresh mutable object each time +export const getMockHousehold = (): Household => ({ + id: TEST_HOUSEHOLD_ID, + countryId: TEST_COUNTRIES.US as any, + householdData: { + people: { + you: { + age: { 2024: 30 }, + employment_income: { 2024: 50000 }, + }, + }, + families: {}, + spm_units: {}, + households: { + 'your household': { + members: ['you'], + }, + }, + marital_units: {}, + tax_units: { + 'your tax unit': { + members: ['you'], + }, + }, + }, +}); + +// Keep a static version for backward compatibility but note it should not be mutated +export const mockHousehold: Household = getMockHousehold(); + +// Mock Redux state +export const mockPopulationState = { + type: 'geographic' as const, + id: null, + label: TEST_POPULATION_LABEL, + geography: mockNationalGeography, + household: null, + isCreated: false, +}; + +export const getMockHouseholdPopulationState = () => ({ + type: 'household' as const, + id: TEST_HOUSEHOLD_ID, + label: TEST_POPULATION_LABEL, + geography: null, + household: getMockHousehold(), + isCreated: false, +}); + +export const mockHouseholdPopulationState = getMockHouseholdPopulationState(); + +export const mockMetadataState = { + currentCountry: TEST_COUNTRIES.US, + entities: { + person: { plural: 'people', label: 'Person' }, + tax_unit: { plural: 'tax_units', label: 'Tax unit' }, + household: { plural: 'households', label: 'Household' }, + }, + variables: { + age: { defaultValue: 30 }, + employment_income: { defaultValue: 0 }, + }, + basic_inputs: { + person: ['age', 'employment_income'], + household: ['state_code'], + }, + variable_metadata: { + state_code: { + possibleValues: [ + { value: 'CA', label: 'California' }, + { value: 'NY', label: 'New York' }, + ], + }, + }, + loading: false, + error: null, +}; + +export const mockRootState: Partial = { + population: mockPopulationState, + metadata: mockMetadataState as any, +}; + +// Mock geographic association +export const mockGeographicAssociation: UserGeographicAssociation = { + id: `${TEST_USER_ID}-${Date.now()}`, + userId: TEST_USER_ID, + countryCode: TEST_COUNTRIES.US, + geographyType: 'national', + geographyIdentifier: TEST_COUNTRIES.US, + label: 'United States', + createdAt: new Date().toISOString(), +}; + +// Mock region data +export const mockUSRegions = { + result: { + economy_options: { + region: [ + { name: 'us', label: 'United States' }, + { name: 'state/ca', label: 'California' }, + { name: 'state/ny', label: 'New York' }, + { name: 'state/tx', label: 'Texas' }, + ], + }, + }, +}; + +export const mockUKRegions = { + result: { + economy_options: { + region: [ + { name: 'uk', label: 'United Kingdom' }, + { name: 'country/england', label: 'England' }, + { name: 'country/scotland', label: 'Scotland' }, + { name: 'constituency/london', label: 'London' }, + { name: 'constituency/manchester', label: 'Manchester' }, + ], + }, + }, +}; + +// Mock flow props +export const mockFlowProps: FlowComponentProps = { + onNavigate: vi.fn(), + onReturn: vi.fn(), + isInSubflow: false, + flowConfig: {} as any, + flowDepth: 0, +}; + +// ============= MOCKS FOR MODULES ============= + +// Mock regions data +export const mockRegions = { + us_regions: mockUSRegions, + uk_regions: mockUKRegions, +}; + +// Mock constants module +export const mockConstants = { + MOCK_USER_ID: TEST_USER_ID, +}; + +// Mock hooks +export const mockCreateGeographicAssociation = vi.fn(); +export const mockResetIngredient = vi.fn(); +export const mockCreateHousehold = vi.fn(); + +export const mockUseCreateGeographicAssociation = () => ({ + mutateAsync: mockCreateGeographicAssociation, + isPending: false, +}); + +export const mockUseIngredientReset = () => ({ + resetIngredient: mockResetIngredient, +}); + +export const mockUseCreateHousehold = () => ({ + createHousehold: mockCreateHousehold, + isPending: false, +}); + +// Mock household utilities +export const mockHouseholdBuilder = vi.fn().mockImplementation((_countryId, _taxYear) => ({ + build: vi.fn(() => getMockHousehold()), + loadHousehold: vi.fn(), + addAdult: vi.fn(), + addChild: vi.fn(), + removePerson: vi.fn(), + setMaritalStatus: vi.fn(), + assignToGroupEntity: vi.fn(), +})); + +export const mockHouseholdQueries = { + getChildCount: vi.fn(() => 0), + getChildren: vi.fn(() => []), + getPersonVariable: vi.fn((_household, _person, variable, _year) => { + if (variable === 'age') { + return TEST_VALUES.DEFAULT_AGE; + } + if (variable === 'employment_income') { + return TEST_VALUES.DEFAULT_INCOME; + } + return 0; + }), +}; + +export const mockHouseholdValidation = { + isReadyForSimulation: vi.fn(() => ({ isValid: true, errors: [] })), +}; + +export const mockHouseholdAdapter = { + toCreationPayload: vi.fn(() => ({ + country_id: TEST_COUNTRIES.US, + data: getMockHousehold().householdData, + })), +}; + +// Mock metadata utilities +export const mockGetTaxYears = () => mockTaxYears; +export const mockGetBasicInputFields = () => ({ + person: ['age', 'employment_income'], + household: ['state_code'], +}); +export const mockGetFieldLabel = (field: string) => { + const labels: Record = { + state_code: PLACEHOLDERS.STATE_CODE, + age: PLACEHOLDERS.AGE, + employment_income: PLACEHOLDERS.EMPLOYMENT_INCOME, + }; + return labels[field] || field; +}; +export const mockIsDropdownField = (field: string) => field === 'state_code'; +export const mockGetFieldOptions = () => [ + { value: 'CA', label: UI_TEXT.STATE_CALIFORNIA }, + { value: 'NY', label: UI_TEXT.STATE_NEW_YORK }, +]; + +// Mock household creation response +export const mockCreateHouseholdResponse = { + result: { + household_id: TEST_HOUSEHOLD_ID, + }, +}; + +// Helper functions for tests +export const createMockStore = (overrides?: Partial) => ({ + getState: vi.fn(() => ({ + ...mockRootState, + ...overrides, + })), + dispatch: vi.fn(), + subscribe: vi.fn(), + replaceReducer: vi.fn(), +}); + +export const mockTaxYears = [ + { value: '2024', label: '2024' }, + { value: '2023', label: '2023' }, + { value: '2022', label: '2022' }, +]; diff --git a/app/src/tests/fixtures/hooks/hooksMocks.ts b/app/src/tests/fixtures/hooks/hooksMocks.ts new file mode 100644 index 00000000..c78e9dab --- /dev/null +++ b/app/src/tests/fixtures/hooks/hooksMocks.ts @@ -0,0 +1,343 @@ +import { QueryClient } from '@tanstack/react-query'; +import { vi } from 'vitest'; +import { UserHouseholdPopulation } from '@/types/ingredients/UserPopulation'; +import { HouseholdMetadata } from '@/types/metadata/householdMetadata'; +import { HouseholdCreationPayload } from '@/types/payloads'; +import { UserGeographicAssociation } from '@/types/userIngredientAssociations'; + +// ============= TEST CONSTANTS ============= + +// User and ID constants +export const TEST_IDS = { + USER_ID: 'test-user-123', + HOUSEHOLD_ID: 'household-456', + HOUSEHOLD_ID_2: 'household-789', + GEOGRAPHY_ID: 'geography-123', + GEOGRAPHY_ID_2: 'geography-456', + TIMESTAMP: '2024-01-15T10:00:00Z', +} as const; + +// Labels and descriptions +export const TEST_LABELS = { + HOUSEHOLD: 'Test Household 2024', + HOUSEHOLD_2: 'Second Test Household', + GEOGRAPHY: 'California Region', + GEOGRAPHY_2: 'New York Region', + DEFAULT: 'Default Label', +} as const; + +// Geographic constants +export const GEO_CONSTANTS = { + COUNTRY_US: 'us', + COUNTRY_UK: 'uk', + REGION_CA: 'ca', + REGION_NY: 'ny', + TYPE_NATIONAL: 'national' as const, + TYPE_SUBNATIONAL: 'subnational' as const, + REGION_TYPE_STATE: 'state' as const, + REGION_TYPE_CONSTITUENCY: 'constituency' as const, +} as const; + +// Error messages +export const ERROR_MESSAGES = { + CREATE_FAILED: 'Failed to create household', + ASSOCIATION_FAILED: 'Household created but association failed:', + FETCH_FAILED: 'Failed to fetch', + NETWORK_ERROR: 'Network error', + NOT_FOUND: 'Not found', +} as const; + +// Console log messages for assertions +export const CONSOLE_MESSAGES = { + ASSOCIATION_ERROR: 'Household created but association failed:', + USER_ID_LOG: 'userId', + STORE_LOG: 'store', + LOGGED_IN_LOG: 'isLoggedIn', + CONFIG_LOG: 'config', + ASSOCIATIONS_LOG: 'associations', + HOUSEHOLD_IDS_LOG: 'householdIds', + NEW_ASSOCIATION_LOG: 'new association', + HOUSEHOLD_LOG: 'household in useCreateHouseholdAssociation', +} as const; + +// Query key patterns +export const QUERY_KEY_PATTERNS = { + HOUSEHOLD_ALL: ['households'], + HOUSEHOLD_BY_ID: (id: string) => ['households', 'byId', id], + ASSOCIATION_BY_USER: (userId: string) => ['household-associations', 'byUser', userId], + ASSOCIATION_BY_HOUSEHOLD: (id: string) => ['household-associations', 'byHousehold', id], + ASSOCIATION_SPECIFIC: (userId: string, id: string) => [ + 'household-associations', + 'specific', + userId, + id, + ], + GEO_ASSOCIATION_BY_USER: (userId: string) => ['geographic-associations', 'byUser', userId], + GEO_ASSOCIATION_BY_GEOGRAPHY: (id: string) => ['geographic-associations', 'byGeography', id], + GEO_ASSOCIATION_SPECIFIC: (userId: string, id: string) => [ + 'geographic-associations', + 'specific', + userId, + id, + ], +} as const; + +// Numeric values +export const TEST_VALUES = { + AGE: 30, + INCOME: 50000, + STALE_TIME: 5 * 60 * 1000, // 5 minutes +} as const; + +// ============= MOCK DATA OBJECTS ============= + +export const mockHouseholdMetadata: HouseholdMetadata = { + id: TEST_IDS.HOUSEHOLD_ID.split('-')[1], + country_id: GEO_CONSTANTS.COUNTRY_US, + household_json: { + people: { + person1: { + age: { 2024: TEST_VALUES.AGE }, + employment_income: { 2024: TEST_VALUES.INCOME }, + }, + }, + tax_units: { + unit1: { + members: ['person1'], + }, + }, + spm_units: { + unit1: { + members: ['person1'], + }, + }, + households: {}, + marital_units: {}, + families: {}, + }, + api_version: 'v1', + household_hash: '', +}; + +export const mockUserHouseholdPopulation: UserHouseholdPopulation = { + type: 'household', + id: TEST_IDS.HOUSEHOLD_ID, + householdId: TEST_IDS.HOUSEHOLD_ID, + userId: TEST_IDS.USER_ID, + label: TEST_LABELS.HOUSEHOLD, + createdAt: TEST_IDS.TIMESTAMP, + updatedAt: TEST_IDS.TIMESTAMP, + isCreated: true, +}; + +export const mockUserHouseholdPopulationList: UserHouseholdPopulation[] = [ + mockUserHouseholdPopulation, + { + ...mockUserHouseholdPopulation, + id: TEST_IDS.HOUSEHOLD_ID_2, + householdId: TEST_IDS.HOUSEHOLD_ID_2, + label: TEST_LABELS.HOUSEHOLD_2, + }, +]; + +export const mockUserGeographicAssociation: UserGeographicAssociation = { + id: TEST_IDS.GEOGRAPHY_ID, + userId: TEST_IDS.USER_ID, + countryCode: GEO_CONSTANTS.COUNTRY_US, + geographyType: GEO_CONSTANTS.TYPE_SUBNATIONAL, + geographyIdentifier: `${GEO_CONSTANTS.COUNTRY_US}-${GEO_CONSTANTS.REGION_CA}`, + regionCode: GEO_CONSTANTS.REGION_CA, + regionType: GEO_CONSTANTS.REGION_TYPE_STATE, + label: TEST_LABELS.GEOGRAPHY, + createdAt: TEST_IDS.TIMESTAMP, +}; + +export const mockUserGeographicAssociationList: UserGeographicAssociation[] = [ + mockUserGeographicAssociation, + { + ...mockUserGeographicAssociation, + id: TEST_IDS.GEOGRAPHY_ID_2, + geographyIdentifier: `${GEO_CONSTANTS.COUNTRY_US}-${GEO_CONSTANTS.REGION_NY}`, + regionCode: GEO_CONSTANTS.REGION_NY, + label: TEST_LABELS.GEOGRAPHY_2, + }, +]; + +export const mockHouseholdCreationPayload: HouseholdCreationPayload = { + country_id: GEO_CONSTANTS.COUNTRY_US, + data: { + people: { + person1: { + age: { 2024: TEST_VALUES.AGE }, + }, + }, + tax_units: { + unit1: { + members: ['person1'], + }, + }, + spm_units: { + unit1: { + members: ['person1'], + }, + }, + households: { + household1: { + members: ['person1', 'person2'], + }, + }, + marital_units: { + unit1: { + members: ['person1', 'person2'], + }, + }, + families: { + family1: { + members: ['person1', 'person2'], + }, + }, + }, +}; + +export const mockCreateHouseholdResponse = { + result: { + household_id: TEST_IDS.HOUSEHOLD_ID, + }, +}; + +// ============= MOCK FUNCTIONS ============= + +// API mocks +export const mockCreateHousehold = vi.fn(); +export const mockFetchHouseholdById = vi.fn(); + +// Store mocks +export const mockHouseholdStoreCreate = vi.fn(); +export const mockHouseholdStoreFindByUser = vi.fn(); +export const mockHouseholdStoreFindById = vi.fn(); + +export const mockGeographicStoreCreate = vi.fn(); +export const mockGeographicStoreFindByUser = vi.fn(); +export const mockGeographicStoreFindById = vi.fn(); + +// Hook mocks +export const mockCreateHouseholdAssociationMutateAsync = vi.fn(); +export const mockUseCreateHouseholdAssociation = { + mutateAsync: mockCreateHouseholdAssociationMutateAsync, +}; + +// Query client mock +export const createMockQueryClient = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + // Spy on methods we'll assert against + vi.spyOn(queryClient, 'invalidateQueries'); + vi.spyOn(queryClient, 'setQueryData'); + + return queryClient; +}; + +// Redux state mock +export const mockReduxState = { + metadata: { + currentCountry: GEO_CONSTANTS.COUNTRY_US, + }, +}; + +// ============= MOCK MODULES ============= + +// Mock constants module +export const mockConstants = { + MOCK_USER_ID: TEST_IDS.USER_ID, +}; + +// Mock query keys +export const mockHouseholdKeys = { + all: QUERY_KEY_PATTERNS.HOUSEHOLD_ALL, + byId: QUERY_KEY_PATTERNS.HOUSEHOLD_BY_ID, +}; + +export const mockHouseholdAssociationKeys = { + byUser: QUERY_KEY_PATTERNS.ASSOCIATION_BY_USER, + byHousehold: QUERY_KEY_PATTERNS.ASSOCIATION_BY_HOUSEHOLD, + specific: QUERY_KEY_PATTERNS.ASSOCIATION_SPECIFIC, +}; + +export const mockGeographicAssociationKeys = { + byUser: QUERY_KEY_PATTERNS.GEO_ASSOCIATION_BY_USER, + byGeography: QUERY_KEY_PATTERNS.GEO_ASSOCIATION_BY_GEOGRAPHY, + specific: QUERY_KEY_PATTERNS.GEO_ASSOCIATION_SPECIFIC, +}; + +// Mock stores +export const mockApiHouseholdStore = { + create: mockHouseholdStoreCreate, + findByUser: mockHouseholdStoreFindByUser, + findById: mockHouseholdStoreFindById, +}; + +export const mockSessionStorageHouseholdStore = { + create: mockHouseholdStoreCreate, + findByUser: mockHouseholdStoreFindByUser, + findById: mockHouseholdStoreFindById, +}; + +export const mockApiGeographicStore = { + create: mockGeographicStoreCreate, + findByUser: mockGeographicStoreFindByUser, + findById: mockGeographicStoreFindById, +}; + +export const mockSessionStorageGeographicStore = { + create: mockGeographicStoreCreate, + findByUser: mockGeographicStoreFindByUser, + findById: mockGeographicStoreFindById, +}; + +// Mock query config +export const mockQueryConfig = { + api: { + staleTime: TEST_VALUES.STALE_TIME, + cacheTime: TEST_VALUES.STALE_TIME * 2, + }, + sessionStorage: { + staleTime: 0, + cacheTime: 0, + }, +}; + +// ============= TEST HELPERS ============= + +export const setupMockConsole = () => { + const consoleSpy = { + log: vi.spyOn(console, 'log').mockImplementation(() => {}), + error: vi.spyOn(console, 'error').mockImplementation(() => {}), + warn: vi.spyOn(console, 'warn').mockImplementation(() => {}), + }; + + return { + consoleSpy, + restore: () => { + consoleSpy.log.mockRestore(); + consoleSpy.error.mockRestore(); + consoleSpy.warn.mockRestore(); + }, + }; +}; + +export const resetAllMocks = () => { + mockCreateHousehold.mockReset(); + mockFetchHouseholdById.mockReset(); + mockHouseholdStoreCreate.mockReset(); + mockHouseholdStoreFindByUser.mockReset(); + mockHouseholdStoreFindById.mockReset(); + mockGeographicStoreCreate.mockReset(); + mockGeographicStoreFindByUser.mockReset(); + mockGeographicStoreFindById.mockReset(); + mockCreateHouseholdAssociationMutateAsync.mockReset(); +}; diff --git a/app/src/tests/fixtures/pages/populationsMocks.ts b/app/src/tests/fixtures/pages/populationsMocks.ts new file mode 100644 index 00000000..16c9316d --- /dev/null +++ b/app/src/tests/fixtures/pages/populationsMocks.ts @@ -0,0 +1,340 @@ +import { vi } from 'vitest'; +import { UserHouseholdPopulation } from '@/types/ingredients/UserPopulation'; +import { HouseholdMetadata } from '@/types/metadata/householdMetadata'; +import { UserGeographicAssociation } from '@/types/userIngredientAssociations'; + +// ============= TEST CONSTANTS ============= + +// IDs and identifiers +export const POPULATION_TEST_IDS = { + USER_ID: 'test-user-123', + HOUSEHOLD_ID_1: 'household-001', + HOUSEHOLD_ID_2: 'household-002', + GEOGRAPHIC_ID_1: 'geo-001', + GEOGRAPHIC_ID_2: 'geo-002', + TIMESTAMP_1: '2024-01-15T10:00:00Z', + TIMESTAMP_2: '2024-01-20T14:30:00Z', +} as const; + +// Labels and display text +export const POPULATION_LABELS = { + HOUSEHOLD_1: 'My Test Household', + HOUSEHOLD_2: 'Family Household 2024', + GEOGRAPHIC_1: 'California Population', + GEOGRAPHIC_2: 'United Kingdom National', + PAGE_TITLE: 'Your populations', + PAGE_SUBTITLE: + 'Create a population configuration or find and save existing populations to use in your simulation configurations.', + BUILD_BUTTON: 'Build population', + SEARCH_PLACEHOLDER: 'Search populations', + MORE_FILTERS: 'More filters', + LOADING_TEXT: 'Loading...', + ERROR_TEXT: 'Error loading data', +} as const; + +// Geographic constants +export const POPULATION_GEO = { + COUNTRY_US: 'us', + COUNTRY_UK: 'uk', + STATE_CA: 'ca', + STATE_NY: 'ny', + TYPE_NATIONAL: 'national' as const, + TYPE_SUBNATIONAL: 'subnational' as const, + REGION_TYPE_STATE: 'state' as const, + REGION_TYPE_CONSTITUENCY: 'constituency' as const, +} as const; + +// Menu actions +export const POPULATION_ACTIONS = { + VIEW_DETAILS: 'view-population', + BOOKMARK: 'bookmark', + EDIT: 'edit', + SHARE: 'share', + DELETE: 'delete', +} as const; + +// Action labels +export const POPULATION_ACTION_LABELS = { + VIEW_DETAILS: 'View details', + BOOKMARK: 'Bookmark', + EDIT: 'Edit', + SHARE: 'Share', + DELETE: 'Delete', +} as const; + +// Column headers +export const POPULATION_COLUMNS = { + NAME: 'Population name', + DATE: 'Date created', + DETAILS: 'Details', + CONNECTIONS: 'Connections', +} as const; + +// Detail text patterns +export const POPULATION_DETAILS = { + PERSON_SINGULAR: '1 person', + PERSON_PLURAL: (count: number) => `${count} persons`, + HOUSEHOLD_SINGULAR: '1 household', + HOUSEHOLD_PLURAL: (count: number) => `${count} households`, + NATIONAL: 'National', + SUBNATIONAL: 'Subnational', + STATE_PREFIX: 'State:', + CONSTITUENCY_PREFIX: 'Constituency:', + SAMPLE_SIMULATION: 'Sample simulation', + SAMPLE_REPORT: 'Sample report', + AVAILABLE_FOR_SIMULATIONS: 'Available for simulations', +} as const; + +// Console log messages +export const POPULATION_CONSOLE = { + MORE_FILTERS: 'More filters clicked', + VIEW_DETAILS: (id: string) => `View details: ${id}`, + BOOKMARK: (id: string) => `Bookmark population: ${id}`, + EDIT: (id: string) => `Edit population: ${id}`, + SHARE: (id: string) => `Share population: ${id}`, + DELETE: (id: string) => `Delete population: ${id}`, + UNKNOWN_ACTION: (action: string) => `Unknown action: ${action}`, +} as const; + +// Error messages +export const POPULATION_ERRORS = { + HOUSEHOLD_FETCH_FAILED: 'Failed to fetch household data', + GEOGRAPHIC_FETCH_FAILED: 'Failed to fetch geographic data', + COMBINED_ERROR: 'Error loading population data', +} as const; + +// ============= MOCK DATA OBJECTS ============= + +// Mock household metadata +export const mockHouseholdMetadata1: HouseholdMetadata = { + id: POPULATION_TEST_IDS.HOUSEHOLD_ID_1.split('-')[1], + country_id: POPULATION_GEO.COUNTRY_US, + household_json: { + people: { + person1: { + age: { 2024: 30 }, + employment_income: { 2024: 50000 }, + }, + person2: { + age: { 2024: 28 }, + employment_income: { 2024: 45000 }, + }, + }, + families: { + family1: { + members: ['person1', 'person2'], + }, + }, + tax_units: { + unit1: { + members: ['person1'], + }, + }, + spm_units: { + unit1: { + members: ['person1'], + }, + }, + households: { + household1: { + members: ['person1', 'person2'], + }, + }, + marital_units: { + unit1: { + members: ['person1', 'person2'], + }, + }, + }, + api_version: 'v1', + household_hash: '', +}; + +export const mockHouseholdMetadata2: HouseholdMetadata = { + id: POPULATION_TEST_IDS.HOUSEHOLD_ID_2.split('-')[1], + country_id: POPULATION_GEO.COUNTRY_US, + household_json: { + people: { + person1: { + age: { 2024: 45 }, + }, + }, + families: {}, + tax_units: { + unit1: { + members: ['person1'], + }, + }, + spm_units: { + unit1: { + members: ['person1'], + }, + }, + households: { + household1: { + members: ['person1', 'person2'], + }, + }, + marital_units: { + unit1: { + members: ['person1', 'person2'], + }, + }, + }, + api_version: 'v1', + household_hash: '', +}; + +// Mock household associations +export const mockHouseholdAssociation1: UserHouseholdPopulation = { + type: 'household', + id: POPULATION_TEST_IDS.HOUSEHOLD_ID_1, + householdId: POPULATION_TEST_IDS.HOUSEHOLD_ID_1, + userId: POPULATION_TEST_IDS.USER_ID, + label: POPULATION_LABELS.HOUSEHOLD_1, + createdAt: POPULATION_TEST_IDS.TIMESTAMP_1, + updatedAt: POPULATION_TEST_IDS.TIMESTAMP_1, + isCreated: true, +}; + +export const mockHouseholdAssociation2: UserHouseholdPopulation = { + type: 'household', + id: POPULATION_TEST_IDS.HOUSEHOLD_ID_2, + householdId: POPULATION_TEST_IDS.HOUSEHOLD_ID_2, + userId: POPULATION_TEST_IDS.USER_ID, + label: POPULATION_LABELS.HOUSEHOLD_2, + createdAt: POPULATION_TEST_IDS.TIMESTAMP_2, + updatedAt: POPULATION_TEST_IDS.TIMESTAMP_2, + isCreated: true, +}; + +// Mock geographic associations +export const mockGeographicAssociation1: UserGeographicAssociation = { + id: POPULATION_TEST_IDS.GEOGRAPHIC_ID_1, + userId: POPULATION_TEST_IDS.USER_ID, + countryCode: POPULATION_GEO.COUNTRY_US, + geographyType: POPULATION_GEO.TYPE_SUBNATIONAL, + geographyIdentifier: `${POPULATION_GEO.COUNTRY_US}-${POPULATION_GEO.STATE_CA}`, + regionCode: POPULATION_GEO.STATE_CA, + regionType: POPULATION_GEO.REGION_TYPE_STATE, + label: POPULATION_LABELS.GEOGRAPHIC_1, + createdAt: POPULATION_TEST_IDS.TIMESTAMP_1, +}; + +export const mockGeographicAssociation2: UserGeographicAssociation = { + id: POPULATION_TEST_IDS.GEOGRAPHIC_ID_2, + userId: POPULATION_TEST_IDS.USER_ID, + countryCode: POPULATION_GEO.COUNTRY_UK, + geographyType: POPULATION_GEO.TYPE_NATIONAL, + geographyIdentifier: POPULATION_GEO.COUNTRY_UK, + label: POPULATION_LABELS.GEOGRAPHIC_2, + createdAt: POPULATION_TEST_IDS.TIMESTAMP_2, +}; + +// Combined mock data for useUserHouseholds hook +export const mockUserHouseholdsData = [ + { + association: mockHouseholdAssociation1, + household: mockHouseholdMetadata1, + isLoading: false, + error: null, + }, + { + association: mockHouseholdAssociation2, + household: mockHouseholdMetadata2, + isLoading: false, + error: null, + }, +]; + +export const mockGeographicAssociationsData = [ + mockGeographicAssociation1, + mockGeographicAssociation2, +]; + +// ============= MOCK FUNCTIONS ============= + +// Redux dispatch mock +export const mockDispatch = vi.fn(); + +// Hook mocks +export const mockUseUserHouseholds = vi.fn(() => ({ + data: mockUserHouseholdsData, + isLoading: false, + isError: false, + error: null, +})); + +export const mockUseGeographicAssociationsByUser = vi.fn(() => ({ + data: mockGeographicAssociationsData, + isLoading: false, + isError: false, + error: null, +})); + +// ============= TEST HELPERS ============= + +export const setupMockConsole = () => { + const consoleSpy = { + log: vi.spyOn(console, 'log').mockImplementation(() => {}), + error: vi.spyOn(console, 'error').mockImplementation(() => {}), + warn: vi.spyOn(console, 'warn').mockImplementation(() => {}), + }; + + return { + consoleSpy, + restore: () => { + consoleSpy.log.mockRestore(); + consoleSpy.error.mockRestore(); + consoleSpy.warn.mockRestore(); + }, + }; +}; + +// Helper to create loading states +export const createLoadingState = (householdLoading = true, geographicLoading = false) => ({ + household: { + data: householdLoading ? undefined : mockUserHouseholdsData, + isLoading: householdLoading, + isError: false, + error: null, + }, + geographic: { + data: geographicLoading ? undefined : mockGeographicAssociationsData, + isLoading: geographicLoading, + isError: false, + error: null, + }, +}); + +// Helper to create error states +export const createErrorState = (householdError = false, geographicError = false) => ({ + household: { + data: householdError ? undefined : mockUserHouseholdsData, + isLoading: false, + isError: householdError, + error: householdError ? new Error(POPULATION_ERRORS.HOUSEHOLD_FETCH_FAILED) : null, + }, + geographic: { + data: geographicError ? undefined : mockGeographicAssociationsData, + isLoading: false, + isError: geographicError, + error: geographicError ? new Error(POPULATION_ERRORS.GEOGRAPHIC_FETCH_FAILED) : null, + }, +}); + +// Helper to create empty data states +export const createEmptyDataState = () => ({ + household: { + data: [], + isLoading: false, + isError: false, + error: null, + }, + geographic: { + data: [], + isLoading: false, + isError: false, + error: null, + }, +}); diff --git a/app/src/tests/fixtures/reducers/populationReducerMocks.ts b/app/src/tests/fixtures/reducers/populationReducerMocks.ts new file mode 100644 index 00000000..05093758 --- /dev/null +++ b/app/src/tests/fixtures/reducers/populationReducerMocks.ts @@ -0,0 +1,289 @@ +import { vi } from 'vitest'; +import { Geography } from '@/types/ingredients/Geography'; +import { Household } from '@/types/ingredients/Household'; +import { Population } from '@/types/ingredients/Population'; + +// ============= TEST CONSTANTS ============= + +// IDs and identifiers +export const POPULATION_IDS = { + HOUSEHOLD_ID: 'household-123', + HOUSEHOLD_ID_NEW: 'household-456', + GEOGRAPHY_ID: 'geo-789', + GEOGRAPHY_ID_NEW: 'geo-012', + PERSON_ID_1: 'person-001', + PERSON_ID_2: 'person-002', + FAMILY_ID: 'family-001', + TAX_UNIT_ID: 'taxunit-001', + SPM_UNIT_ID: 'spmunit-001', + MARITAL_UNIT_ID: 'maritalunit-001', + BENEFIT_UNIT_ID: 'benunit-001', +} as const; + +// Labels +export const POPULATION_LABELS = { + DEFAULT: 'Test Population', + UPDATED: 'Updated Population Label', + HOUSEHOLD: 'My Household Configuration', + GEOGRAPHY: 'California State Population', +} as const; + +// Countries and regions +export const POPULATION_COUNTRIES = { + US: 'us', + UK: 'uk', + CA: 'ca', + NG: 'ng', + IL: 'il', +} as const; + +export const POPULATION_REGIONS = { + CALIFORNIA: 'ca', + NEW_YORK: 'ny', + ENGLAND: 'england', + SCOTLAND: 'scotland', +} as const; + +// Years +export const POPULATION_YEARS = { + DEFAULT: '2024', + PAST: '2023', + FUTURE: '2025', +} as const; + +// Action types (for testing action creators) +export const POPULATION_ACTION_TYPES = { + CLEAR: 'population/clearPopulation', + UPDATE_ID: 'population/updatePopulationId', + UPDATE_LABEL: 'population/updatePopulationLabel', + MARK_CREATED: 'population/markPopulationAsCreated', + SET_HOUSEHOLD: 'population/setHousehold', + INITIALIZE_HOUSEHOLD: 'population/initializeHousehold', + SET_GEOGRAPHY: 'population/setGeography', +} as const; + +// ============= MOCK DATA OBJECTS ============= + +// Mock household data +export const mockHousehold: Household = { + id: POPULATION_IDS.HOUSEHOLD_ID, + countryId: POPULATION_COUNTRIES.US as any, + householdData: { + people: { + [POPULATION_IDS.PERSON_ID_1]: { + age: { + [POPULATION_YEARS.DEFAULT]: 30, + }, + employment_income: { + [POPULATION_YEARS.DEFAULT]: 50000, + }, + }, + [POPULATION_IDS.PERSON_ID_2]: { + age: { + [POPULATION_YEARS.DEFAULT]: 28, + }, + employment_income: { + [POPULATION_YEARS.DEFAULT]: 45000, + }, + }, + }, + families: { + [POPULATION_IDS.FAMILY_ID]: { + members: [POPULATION_IDS.PERSON_ID_1, POPULATION_IDS.PERSON_ID_2], + }, + }, + households: { + 'your household': { + members: [POPULATION_IDS.PERSON_ID_1, POPULATION_IDS.PERSON_ID_2], + }, + }, + }, +}; + +export const mockHouseholdUK: Household = { + id: POPULATION_IDS.HOUSEHOLD_ID, + countryId: POPULATION_COUNTRIES.UK as any, + householdData: { + people: { + [POPULATION_IDS.PERSON_ID_1]: { + age: { + [POPULATION_YEARS.DEFAULT]: 35, + }, + }, + }, + benunits: { + [POPULATION_IDS.BENEFIT_UNIT_ID]: { + members: [POPULATION_IDS.PERSON_ID_1], + }, + }, + households: { + 'your household': { + members: [POPULATION_IDS.PERSON_ID_1], + }, + }, + }, +}; + +// Mock geography data +export const mockGeography: Geography = { + id: POPULATION_IDS.GEOGRAPHY_ID, + countryId: POPULATION_COUNTRIES.US as any, + scope: 'subnational', + geographyId: `${POPULATION_COUNTRIES.US}-${POPULATION_REGIONS.CALIFORNIA}`, + name: 'California', +}; + +export const mockGeographyNational: Geography = { + id: POPULATION_IDS.GEOGRAPHY_ID, + countryId: POPULATION_COUNTRIES.UK as any, + scope: 'national', + geographyId: POPULATION_COUNTRIES.UK, + name: 'United Kingdom', +}; + +// Initial state variations +export const mockInitialState: Population = { + label: null, + isCreated: false, + household: null, + geography: null, +}; + +export const mockStateWithHousehold: Population = { + label: POPULATION_LABELS.HOUSEHOLD, + isCreated: false, + household: mockHousehold, + geography: null, +}; + +export const mockStateWithGeography: Population = { + label: POPULATION_LABELS.GEOGRAPHY, + isCreated: false, + household: null, + geography: mockGeography, +}; + +export const mockStateCreated: Population = { + label: POPULATION_LABELS.DEFAULT, + isCreated: true, + household: mockHousehold, + geography: null, +}; + +export const mockStateComplete: Population = { + label: POPULATION_LABELS.DEFAULT, + isCreated: true, + household: mockHousehold, + geography: null, +}; + +// ============= MOCK FUNCTIONS ============= + +// Mock HouseholdBuilder +export const mockHouseholdBuilderBuild = vi.fn(); +export const mockHouseholdBuilder = vi.fn(() => ({ + build: mockHouseholdBuilderBuild, +})); + +// Default mock implementation for HouseholdBuilder +export const setupMockHouseholdBuilder = (returnValue: Household = mockHousehold) => { + mockHouseholdBuilderBuild.mockReturnValue(returnValue); + return mockHouseholdBuilder; +}; + +// ============= TEST HELPERS ============= + +// Helper to create a new household for a specific country +export const createMockHouseholdForCountry = (countryId: string): Household => { + const baseHousehold: Household = { + id: `household-${countryId}`, + countryId: countryId as any, + householdData: { + people: { + [POPULATION_IDS.PERSON_ID_1]: { + age: { + [POPULATION_YEARS.DEFAULT]: 30, + }, + }, + }, + households: { + 'your household': { + members: [POPULATION_IDS.PERSON_ID_1], + }, + }, + }, + }; + + // Add country-specific entities + switch (countryId) { + case POPULATION_COUNTRIES.US: + baseHousehold.householdData.families = { + [POPULATION_IDS.FAMILY_ID]: { + members: [POPULATION_IDS.PERSON_ID_1], + }, + }; + baseHousehold.householdData.taxUnits = { + [POPULATION_IDS.TAX_UNIT_ID]: { + members: [POPULATION_IDS.PERSON_ID_1], + }, + }; + baseHousehold.householdData.spmUnits = { + [POPULATION_IDS.SPM_UNIT_ID]: { + members: [POPULATION_IDS.PERSON_ID_1], + }, + }; + baseHousehold.householdData.maritalUnits = { + [POPULATION_IDS.MARITAL_UNIT_ID]: { + members: [POPULATION_IDS.PERSON_ID_1], + }, + }; + break; + case POPULATION_COUNTRIES.UK: + baseHousehold.householdData.benunits = { + [POPULATION_IDS.BENEFIT_UNIT_ID]: { + members: [POPULATION_IDS.PERSON_ID_1], + }, + }; + break; + // Other countries just have people and households + } + + return baseHousehold; +}; + +// Helper to create a geography for testing +export const createMockGeography = ( + countryCode: string, + scope: 'national' | 'subnational' = 'national', + regionCode?: string +): Geography => { + const geography: Geography = { + id: `geo-${countryCode}`, + countryId: countryCode as any, + scope, + geographyId: scope === 'national' ? countryCode : `${countryCode}-${regionCode}`, + name: `Test ${countryCode.toUpperCase()} Geography`, + }; + + return geography; +}; + +// Helper to verify state matches expected +export const expectStateToMatch = (actualState: Population, expectedState: Population): void => { + expect(actualState.label).toBe(expectedState.label); + expect(actualState.isCreated).toBe(expectedState.isCreated); + expect(actualState.household).toEqual(expectedState.household); + expect(actualState.geography).toEqual(expectedState.geography); +}; + +// Helper to create an action payload +export const createAction = (type: string, payload?: T) => ({ + type, + payload, +}); + +// Reset all mocks +export const resetAllMocks = () => { + mockHouseholdBuilderBuild.mockReset(); + mockHouseholdBuilder.mockClear(); +}; diff --git a/app/src/tests/fixtures/utils/householdBuilderMocks.ts b/app/src/tests/fixtures/utils/householdBuilderMocks.ts new file mode 100644 index 00000000..4cf5157e --- /dev/null +++ b/app/src/tests/fixtures/utils/householdBuilderMocks.ts @@ -0,0 +1,367 @@ +import { + Household, + HouseholdData, + HouseholdGroupEntity, + HouseholdPerson, +} from '@/types/ingredients/Household'; + +// ============= TEST CONSTANTS ============= + +// Person names and IDs +export const PERSON_NAMES = { + ADULT_1: 'you', + ADULT_2: 'your partner', + CHILD_1: 'your first child', + CHILD_2: 'your second child', + CHILD_BASE: 'child', +} as const; + +// Ages +export const PERSON_AGES = { + ADULT_DEFAULT: 30, + ADULT_SENIOR: 65, + ADULT_YOUNG: 18, + CHILD_DEFAULT: 10, + CHILD_TEEN: 16, + CHILD_TODDLER: 2, +} as const; + +// Countries +export const COUNTRIES = { + US: 'us', + UK: 'uk', + CA: 'ca', + NG: 'ng', + IL: 'il', + INVALID: 'xx', +} as const; + +// Years +export const YEARS = { + CURRENT: '2024', + PAST: '2023', + FUTURE: '2025', + INVALID: '24', + INVALID_TEXT: 'year', +} as const; + +// Entity names +export const ENTITY_NAMES = { + PEOPLE: 'people', + HOUSEHOLDS: 'households', + FAMILIES: 'families', + TAX_UNITS: 'taxUnits', + SPM_UNITS: 'spmUnits', + MARITAL_UNITS: 'maritalUnits', + BEN_UNITS: 'benunits', +} as const; + +// Group keys +export const GROUP_KEYS = { + DEFAULT_HOUSEHOLD: 'your household', + DEFAULT_FAMILY: 'your family', + DEFAULT_TAX_UNIT: 'your tax unit', + DEFAULT_SPM_UNIT: 'your spm unit', + DEFAULT_MARITAL_UNIT: 'your marital unit', + DEFAULT_BEN_UNIT: 'your benefit unit', + CUSTOM_GROUP: 'custom group', +} as const; + +// Variable names +export const VARIABLE_NAMES = { + EMPLOYMENT_INCOME: 'employment_income', + IS_MARRIED: 'is_married', + IS_TAX_UNIT_DEPENDENT: 'is_tax_unit_dependent', + STATE_CODE: 'state_code', + CUSTOM_VAR: 'custom_variable', +} as const; + +// Variable values +export const VARIABLE_VALUES = { + INCOME_DEFAULT: 50000, + INCOME_HIGH: 100000, + INCOME_LOW: 20000, + STATE_CA: 'CA', + STATE_NY: 'NY', + BOOLEAN_TRUE: true, + BOOLEAN_FALSE: false, + STRING_VALUE: 'test value', + NUMBER_VALUE: 42, +} as const; + +// Error messages +export const ERROR_MESSAGES = { + INVALID_YEAR: 'currentYear must be a four-digit year string', + YEAR_FORMAT: 'Year must be a four-digit string', + PERSON_NOT_FOUND: (name: string) => `Person ${name} not found`, + GROUP_NOT_FOUND: (group: string, entity: string) => `Group ${group} not found in ${entity}`, +} as const; + +// ============= MOCK DATA OBJECTS ============= + +// Expected country default entities +export const EXPECTED_COUNTRY_ENTITIES = { + us: ['people', 'families', 'taxUnits', 'spmUnits', 'households', 'maritalUnits'], + uk: ['people', 'benunits', 'households'], + ca: ['people', 'households'], + ng: ['people', 'households'], + il: ['people', 'households'], +} as const; + +// Mock person data +export const mockAdultPerson: HouseholdPerson = { + age: { [YEARS.CURRENT]: PERSON_AGES.ADULT_DEFAULT }, +}; + +export const mockAdultWithIncome: HouseholdPerson = { + age: { [YEARS.CURRENT]: PERSON_AGES.ADULT_DEFAULT }, + [VARIABLE_NAMES.EMPLOYMENT_INCOME]: { [YEARS.CURRENT]: VARIABLE_VALUES.INCOME_DEFAULT }, +}; + +export const mockChildPerson: HouseholdPerson = { + age: { [YEARS.CURRENT]: PERSON_AGES.CHILD_DEFAULT }, +}; + +export const mockChildUSDependent: HouseholdPerson = { + age: { [YEARS.CURRENT]: PERSON_AGES.CHILD_DEFAULT }, + [VARIABLE_NAMES.IS_TAX_UNIT_DEPENDENT]: { [YEARS.CURRENT]: VARIABLE_VALUES.BOOLEAN_TRUE }, +}; + +// Mock household structures +export const createEmptyHouseholdData = (countryId: string): HouseholdData => { + const data: HouseholdData = { + people: {}, + }; + + const entities = EXPECTED_COUNTRY_ENTITIES[ + countryId as keyof typeof EXPECTED_COUNTRY_ENTITIES + ] || ['people', 'households']; + + for (const entity of entities) { + if (entity !== 'people') { + data[entity] = {}; + } + } + + return data; +}; + +export const createEmptyHousehold = (countryId: string): Household => ({ + countryId: countryId as any, + householdData: createEmptyHouseholdData(countryId), +}); + +// Mock US household with one adult +export const mockUSHouseholdOneAdult: Household = { + countryId: COUNTRIES.US as any, + householdData: { + people: { + [PERSON_NAMES.ADULT_1]: mockAdultPerson, + }, + families: {}, + taxUnits: { + [GROUP_KEYS.DEFAULT_TAX_UNIT]: { + members: [PERSON_NAMES.ADULT_1], + }, + }, + spmUnits: {}, + households: { + [GROUP_KEYS.DEFAULT_HOUSEHOLD]: { + members: [PERSON_NAMES.ADULT_1], + }, + }, + maritalUnits: {}, + }, +}; + +// Mock UK household with one adult +export const mockUKHouseholdOneAdult: Household = { + countryId: COUNTRIES.UK as any, + householdData: { + people: { + [PERSON_NAMES.ADULT_1]: mockAdultPerson, + }, + benunits: { + [GROUP_KEYS.DEFAULT_BEN_UNIT]: { + members: [PERSON_NAMES.ADULT_1], + }, + }, + households: { + [GROUP_KEYS.DEFAULT_HOUSEHOLD]: { + members: [PERSON_NAMES.ADULT_1], + }, + }, + }, +}; + +// Mock US household with married couple +export const mockUSHouseholdMarried: Household = { + countryId: COUNTRIES.US as any, + householdData: { + people: { + [PERSON_NAMES.ADULT_1]: mockAdultPerson, + [PERSON_NAMES.ADULT_2]: mockAdultPerson, + }, + families: {}, + taxUnits: { + [GROUP_KEYS.DEFAULT_TAX_UNIT]: { + members: [PERSON_NAMES.ADULT_1, PERSON_NAMES.ADULT_2], + }, + }, + spmUnits: {}, + households: { + [GROUP_KEYS.DEFAULT_HOUSEHOLD]: { + members: [PERSON_NAMES.ADULT_1, PERSON_NAMES.ADULT_2], + }, + }, + maritalUnits: { + [GROUP_KEYS.DEFAULT_MARITAL_UNIT]: { + members: [PERSON_NAMES.ADULT_1, PERSON_NAMES.ADULT_2], + }, + }, + }, +}; + +// Mock household with custom variables +export const mockHouseholdWithVariables: Household = { + countryId: COUNTRIES.US as any, + householdData: { + people: { + [PERSON_NAMES.ADULT_1]: { + age: { [YEARS.CURRENT]: PERSON_AGES.ADULT_DEFAULT }, + [VARIABLE_NAMES.EMPLOYMENT_INCOME]: { [YEARS.CURRENT]: VARIABLE_VALUES.INCOME_DEFAULT }, + [VARIABLE_NAMES.STATE_CODE]: { [YEARS.CURRENT]: VARIABLE_VALUES.STATE_CA }, + }, + }, + households: { + [GROUP_KEYS.DEFAULT_HOUSEHOLD]: { + members: [PERSON_NAMES.ADULT_1], + [VARIABLE_NAMES.STATE_CODE]: { [YEARS.CURRENT]: VARIABLE_VALUES.STATE_CA }, + }, + }, + }, +}; + +// ============= TEST HELPERS ============= + +// Helper to verify household structure +export const verifyHouseholdStructure = (household: Household, countryId: string): void => { + expect(household.countryId).toBe(countryId); + expect(household.householdData).toBeDefined(); + expect(household.householdData.people).toBeDefined(); + + const expectedEntities = + EXPECTED_COUNTRY_ENTITIES[countryId as keyof typeof EXPECTED_COUNTRY_ENTITIES]; + if (expectedEntities) { + for (const entity of expectedEntities) { + if (entity !== 'people') { + expect(household.householdData[entity]).toBeDefined(); + } + } + } +}; + +// Helper to verify person exists in household +export const verifyPersonExists = ( + household: Household, + personName: string, + expectedAge?: number +): void => { + const person = household.householdData.people[personName]; + expect(person).toBeDefined(); + + if (expectedAge !== undefined) { + const ageValues = Object.values(person.age); + expect(ageValues[0]).toBe(expectedAge); + } +}; + +// Helper to verify person is in group +export const verifyPersonInGroup = ( + household: Household, + personName: string, + entityName: string, + groupKey: string +): void => { + const entities = household.householdData[entityName] as Record; + expect(entities).toBeDefined(); + expect(entities[groupKey]).toBeDefined(); + expect(entities[groupKey].members).toContain(personName); +}; + +// Helper to verify person not in any group +export const verifyPersonNotInAnyGroup = (household: Household, personName: string): void => { + Object.keys(household.householdData).forEach((entityName) => { + if (entityName === 'people') { + return; + } + + const entities = household.householdData[entityName] as Record; + Object.values(entities).forEach((group) => { + if (group.members) { + expect(group.members).not.toContain(personName); + } + }); + }); +}; + +// Helper to verify variable value +export const verifyVariableValue = ( + entity: any, + variableName: string, + expectedValue: any, + year: string = YEARS.CURRENT +): void => { + expect(entity[variableName]).toBeDefined(); + expect(entity[variableName][year]).toBe(expectedValue); +}; + +// Helper to count people in household +export const countPeople = (household: Household): number => { + return Object.keys(household.householdData.people).length; +}; + +// Helper to count members in group +export const countGroupMembers = ( + household: Household, + entityName: string, + groupKey: string +): number => { + const entities = household.householdData[entityName] as Record; + if (!entities || !entities[groupKey]) { + return 0; + } + return entities[groupKey].members?.length || 0; +}; + +// Helper to get all group keys for an entity +export const getGroupKeys = (household: Household, entityName: string): string[] => { + const entities = household.householdData[entityName]; + if (!entities || typeof entities !== 'object') { + return []; + } + return Object.keys(entities); +}; + +// Helper to create mock variables object +export const createMockVariables = (income?: number, state?: string): Record => { + const vars: Record = {}; + + if (income !== undefined) { + vars[VARIABLE_NAMES.EMPLOYMENT_INCOME] = income; + } + + if (state !== undefined) { + vars[VARIABLE_NAMES.STATE_CODE] = state; + } + + return vars; +}; + +// Helper to create year-keyed value +export const createYearKeyedValue = ( + value: any, + year: string = YEARS.CURRENT +): Record => { + return { [year]: value }; +}; diff --git a/app/src/tests/fixtures/utils/householdQueriesMocks.ts b/app/src/tests/fixtures/utils/householdQueriesMocks.ts new file mode 100644 index 00000000..54dae7d7 --- /dev/null +++ b/app/src/tests/fixtures/utils/householdQueriesMocks.ts @@ -0,0 +1,409 @@ +import { Household, HouseholdPerson } from '@/types/ingredients/Household'; +import { PersonWithName } from '@/utils/HouseholdQueries'; + +// ============= TEST CONSTANTS ============= + +// Person names +export const QUERY_PERSON_NAMES = { + ADULT_1: 'John', + ADULT_2: 'Jane', + ADULT_3: 'Bob', + CHILD_1: 'Jack', + CHILD_2: 'Jill', + TEEN: 'Teen', + BABY: 'Baby', +} as const; + +// Ages +export const QUERY_AGES = { + ADULT_30: 30, + ADULT_25: 25, + ADULT_65: 65, + ADULT_EXACTLY_18: 18, + CHILD_10: 10, + CHILD_5: 5, + CHILD_ALMOST_18: 17, + BABY_1: 1, +} as const; + +// Countries +export const QUERY_COUNTRIES = { + US: 'us', + UK: 'uk', + CA: 'ca', +} as const; + +// Years +export const QUERY_YEARS = { + CURRENT: '2024', + PAST: '2023', + FUTURE: '2025', + NON_EXISTENT: '2026', +} as const; + +// Entity names +export const QUERY_ENTITY_NAMES = { + PEOPLE: 'people', + HOUSEHOLDS: 'households', + FAMILIES: 'families', + TAX_UNITS: 'taxUnits', + BEN_UNITS: 'benunits', + NON_EXISTENT: 'nonExistentEntity', +} as const; + +// Group keys +export const QUERY_GROUP_KEYS = { + DEFAULT_HOUSEHOLD: 'your household', + DEFAULT_FAMILY: 'your family', + DEFAULT_TAX_UNIT: 'your tax unit', + DEFAULT_BEN_UNIT: 'your benefit unit', + SECOND_HOUSEHOLD: 'second household', + NON_EXISTENT: 'nonExistentGroup', +} as const; + +// Variable names +export const QUERY_VARIABLE_NAMES = { + EMPLOYMENT_INCOME: 'employment_income', + IS_MARRIED: 'is_married', + IS_TAX_UNIT_DEPENDENT: 'is_tax_unit_dependent', + STATE_CODE: 'state_code', + NON_EXISTENT: 'non_existent_variable', + MULTI_YEAR: 'multi_year_variable', +} as const; + +// Variable values +export const QUERY_VARIABLE_VALUES = { + INCOME_50K: 50000, + INCOME_75K: 75000, + INCOME_100K: 100000, + BOOLEAN_TRUE: true, + BOOLEAN_FALSE: false, + STATE_CA: 'CA', + STATE_NY: 'NY', + STRING_VALUE: 'test value', + NUMBER_VALUE: 42, + NULL_VALUE: null, +} as const; + +// Expected counts +export const QUERY_EXPECTED_COUNTS = { + TWO_ADULTS_TWO_CHILDREN: { + TOTAL: 4, + ADULTS: 2, + CHILDREN: 2, + }, + THREE_ADULTS_ONE_CHILD: { + TOTAL: 4, + ADULTS: 3, + CHILDREN: 1, + }, + EMPTY: { + TOTAL: 0, + ADULTS: 0, + CHILDREN: 0, + }, + ONE_ADULT: { + TOTAL: 1, + ADULTS: 1, + CHILDREN: 0, + }, +} as const; + +// ============= MOCK DATA OBJECTS ============= + +// Mock person objects +export const mockAdult30: HouseholdPerson = { + age: { [QUERY_YEARS.CURRENT]: QUERY_AGES.ADULT_30 }, + [QUERY_VARIABLE_NAMES.EMPLOYMENT_INCOME]: { + [QUERY_YEARS.CURRENT]: QUERY_VARIABLE_VALUES.INCOME_50K, + }, +}; + +export const mockAdult25: HouseholdPerson = { + age: { [QUERY_YEARS.CURRENT]: QUERY_AGES.ADULT_25 }, + [QUERY_VARIABLE_NAMES.EMPLOYMENT_INCOME]: { + [QUERY_YEARS.CURRENT]: QUERY_VARIABLE_VALUES.INCOME_75K, + }, +}; + +export const mockChild10: HouseholdPerson = { + age: { [QUERY_YEARS.CURRENT]: QUERY_AGES.CHILD_10 }, + [QUERY_VARIABLE_NAMES.IS_TAX_UNIT_DEPENDENT]: { + [QUERY_YEARS.CURRENT]: QUERY_VARIABLE_VALUES.BOOLEAN_TRUE, + }, +}; + +export const mockChild5: HouseholdPerson = { + age: { [QUERY_YEARS.CURRENT]: QUERY_AGES.CHILD_5 }, + [QUERY_VARIABLE_NAMES.IS_TAX_UNIT_DEPENDENT]: { + [QUERY_YEARS.CURRENT]: QUERY_VARIABLE_VALUES.BOOLEAN_TRUE, + }, +}; + +// Person with age changing over years +export const mockPersonAgeChanging: HouseholdPerson = { + age: { + [QUERY_YEARS.PAST]: QUERY_AGES.CHILD_ALMOST_18, + [QUERY_YEARS.CURRENT]: QUERY_AGES.ADULT_EXACTLY_18, + [QUERY_YEARS.FUTURE]: QUERY_AGES.ADULT_25, + }, +}; + +// Person with multi-year variables +export const mockPersonMultiYear: HouseholdPerson = { + age: { [QUERY_YEARS.CURRENT]: QUERY_AGES.ADULT_30 }, + [QUERY_VARIABLE_NAMES.MULTI_YEAR]: { + [QUERY_YEARS.PAST]: QUERY_VARIABLE_VALUES.NUMBER_VALUE, + [QUERY_YEARS.CURRENT]: QUERY_VARIABLE_VALUES.STRING_VALUE, + [QUERY_YEARS.FUTURE]: QUERY_VARIABLE_VALUES.BOOLEAN_TRUE, + }, +}; + +// Empty household +export const mockEmptyHousehold: Household = { + countryId: QUERY_COUNTRIES.US as any, + householdData: { + people: {}, + households: {}, + }, +}; + +// Household with 2 adults and 2 children +export const mockHouseholdTwoAdultsTwoChildren: Household = { + countryId: QUERY_COUNTRIES.US as any, + householdData: { + people: { + [QUERY_PERSON_NAMES.ADULT_1]: mockAdult30, + [QUERY_PERSON_NAMES.ADULT_2]: mockAdult25, + [QUERY_PERSON_NAMES.CHILD_1]: mockChild10, + [QUERY_PERSON_NAMES.CHILD_2]: mockChild5, + }, + households: { + [QUERY_GROUP_KEYS.DEFAULT_HOUSEHOLD]: { + members: [ + QUERY_PERSON_NAMES.ADULT_1, + QUERY_PERSON_NAMES.ADULT_2, + QUERY_PERSON_NAMES.CHILD_1, + QUERY_PERSON_NAMES.CHILD_2, + ], + [QUERY_VARIABLE_NAMES.STATE_CODE]: { + [QUERY_YEARS.CURRENT]: QUERY_VARIABLE_VALUES.STATE_CA, + }, + }, + }, + families: { + [QUERY_GROUP_KEYS.DEFAULT_FAMILY]: { + members: [ + QUERY_PERSON_NAMES.ADULT_1, + QUERY_PERSON_NAMES.ADULT_2, + QUERY_PERSON_NAMES.CHILD_1, + QUERY_PERSON_NAMES.CHILD_2, + ], + }, + }, + taxUnits: { + [QUERY_GROUP_KEYS.DEFAULT_TAX_UNIT]: { + members: [ + QUERY_PERSON_NAMES.ADULT_1, + QUERY_PERSON_NAMES.ADULT_2, + QUERY_PERSON_NAMES.CHILD_1, + QUERY_PERSON_NAMES.CHILD_2, + ], + }, + }, + }, +}; + +// Household with person whose age changes (child to adult) +export const mockHouseholdAgeTransition: Household = { + countryId: QUERY_COUNTRIES.US as any, + householdData: { + people: { + [QUERY_PERSON_NAMES.TEEN]: mockPersonAgeChanging, + }, + households: { + [QUERY_GROUP_KEYS.DEFAULT_HOUSEHOLD]: { + members: [QUERY_PERSON_NAMES.TEEN], + }, + }, + }, +}; + +// Household with multi-year variables +export const mockHouseholdMultiYear: Household = { + countryId: QUERY_COUNTRIES.US as any, + householdData: { + people: { + [QUERY_PERSON_NAMES.ADULT_1]: mockPersonMultiYear, + }, + households: { + [QUERY_GROUP_KEYS.DEFAULT_HOUSEHOLD]: { + members: [QUERY_PERSON_NAMES.ADULT_1], + }, + }, + }, +}; + +// Household with multiple groups +export const mockHouseholdMultipleGroups: Household = { + countryId: QUERY_COUNTRIES.US as any, + householdData: { + people: { + [QUERY_PERSON_NAMES.ADULT_1]: mockAdult30, + [QUERY_PERSON_NAMES.ADULT_2]: mockAdult25, + [QUERY_PERSON_NAMES.ADULT_3]: { + age: { [QUERY_YEARS.CURRENT]: QUERY_AGES.ADULT_65 }, + }, + }, + households: { + [QUERY_GROUP_KEYS.DEFAULT_HOUSEHOLD]: { + members: [QUERY_PERSON_NAMES.ADULT_1, QUERY_PERSON_NAMES.ADULT_2], + }, + [QUERY_GROUP_KEYS.SECOND_HOUSEHOLD]: { + members: [QUERY_PERSON_NAMES.ADULT_3], + }, + }, + }, +}; + +// UK household with benefit units +export const mockUKHousehold: Household = { + countryId: QUERY_COUNTRIES.UK as any, + householdData: { + people: { + [QUERY_PERSON_NAMES.ADULT_1]: mockAdult30, + [QUERY_PERSON_NAMES.CHILD_1]: mockChild10, + }, + households: { + [QUERY_GROUP_KEYS.DEFAULT_HOUSEHOLD]: { + members: [QUERY_PERSON_NAMES.ADULT_1, QUERY_PERSON_NAMES.CHILD_1], + }, + }, + benunits: { + [QUERY_GROUP_KEYS.DEFAULT_BEN_UNIT]: { + members: [QUERY_PERSON_NAMES.ADULT_1, QUERY_PERSON_NAMES.CHILD_1], + }, + }, + }, +}; + +// ============= EXPECTED RESULTS ============= + +// Expected PersonWithName results +export const expectedAdultWithName1: PersonWithName = { + name: QUERY_PERSON_NAMES.ADULT_1, + ...mockAdult30, +}; + +export const expectedAdultWithName2: PersonWithName = { + name: QUERY_PERSON_NAMES.ADULT_2, + ...mockAdult25, +}; + +export const expectedChildWithName1: PersonWithName = { + name: QUERY_PERSON_NAMES.CHILD_1, + ...mockChild10, +}; + +export const expectedChildWithName2: PersonWithName = { + name: QUERY_PERSON_NAMES.CHILD_2, + ...mockChild5, +}; + +export const expectedAllPeopleTwoAdultsTwoChildren: PersonWithName[] = [ + expectedAdultWithName1, + expectedAdultWithName2, + expectedChildWithName1, + expectedChildWithName2, +]; + +export const expectedAdultsTwoAdultsTwoChildren: PersonWithName[] = [ + expectedAdultWithName1, + expectedAdultWithName2, +]; + +export const expectedChildrenTwoAdultsTwoChildren: PersonWithName[] = [ + expectedChildWithName1, + expectedChildWithName2, +]; + +// Expected group results +export const expectedGroupsHouseholds = [ + { + key: QUERY_GROUP_KEYS.DEFAULT_HOUSEHOLD, + members: [ + QUERY_PERSON_NAMES.ADULT_1, + QUERY_PERSON_NAMES.ADULT_2, + QUERY_PERSON_NAMES.CHILD_1, + QUERY_PERSON_NAMES.CHILD_2, + ], + }, +]; + +export const expectedGroupsMultiple = [ + { + key: QUERY_GROUP_KEYS.DEFAULT_HOUSEHOLD, + members: [QUERY_PERSON_NAMES.ADULT_1, QUERY_PERSON_NAMES.ADULT_2], + }, + { + key: QUERY_GROUP_KEYS.SECOND_HOUSEHOLD, + members: [QUERY_PERSON_NAMES.ADULT_3], + }, +]; + +// ============= TEST HELPERS ============= + +// Helper to create a household with specific people +export const createHouseholdWithPeople = ( + people: Record, + countryId: string = QUERY_COUNTRIES.US +): Household => ({ + countryId: countryId as any, + householdData: { + people, + households: { + [QUERY_GROUP_KEYS.DEFAULT_HOUSEHOLD]: { + members: Object.keys(people), + }, + }, + }, +}); + +// Helper to create a person with age +export const createPersonWithAge = ( + age: number, + year: string = QUERY_YEARS.CURRENT +): HouseholdPerson => ({ + age: { [year]: age }, +}); + +// Helper to create a person with variable +export const createPersonWithVariable = ( + variableName: string, + value: any, + year: string = QUERY_YEARS.CURRENT +): HouseholdPerson => ({ + age: { [year]: QUERY_AGES.ADULT_30 }, + [variableName]: { [year]: value }, +}); + +// Helper to verify PersonWithName +export const verifyPersonWithName = ( + actual: PersonWithName, + expectedName: string, + expectedAge?: number, + year: string = QUERY_YEARS.CURRENT +): void => { + expect(actual.name).toBe(expectedName); + if (expectedAge !== undefined) { + expect(actual.age[year]).toBe(expectedAge); + } +}; + +// Helper to verify array of PersonWithName +export const verifyPeopleArray = (actual: PersonWithName[], expectedNames: string[]): void => { + expect(actual).toHaveLength(expectedNames.length); + const actualNames = actual.map((p) => p.name).sort(); + const sortedExpectedNames = [...expectedNames].sort(); + expect(actualNames).toEqual(sortedExpectedNames); +}; diff --git a/app/src/tests/fixtures/utils/householdValidationMocks.ts b/app/src/tests/fixtures/utils/householdValidationMocks.ts new file mode 100644 index 00000000..6cece22d --- /dev/null +++ b/app/src/tests/fixtures/utils/householdValidationMocks.ts @@ -0,0 +1,477 @@ +import { RootState } from '@/store'; +import { Household, HouseholdPerson } from '@/types/ingredients/Household'; +import { + ValidationError, + ValidationResult, + ValidationWarning, + VariableMetadata, +} from '@/utils/HouseholdValidation'; + +// ============= TEST CONSTANTS ============= + +// Validation codes +export const VALIDATION_ERROR_CODES = { + COUNTRY_MISMATCH: 'COUNTRY_MISMATCH', + INVALID_GROUP_STRUCTURE: 'INVALID_GROUP_STRUCTURE', + INVALID_MARITAL_UNIT: 'INVALID_MARITAL_UNIT', + EMPTY_BENUNIT: 'EMPTY_BENUNIT', + INVALID_TYPE: 'INVALID_TYPE', + NOT_INTEGER: 'NOT_INTEGER', + NO_PEOPLE: 'NO_PEOPLE', +} as const; + +export const VALIDATION_WARNING_CODES = { + MISSING_AGE: 'MISSING_AGE', + NO_TAX_UNITS: 'NO_TAX_UNITS', + PERSON_NOT_IN_TAX_UNIT: 'PERSON_NOT_IN_TAX_UNIT', +} as const; + +// Person names +export const VALIDATION_PERSON_NAMES = { + ADULT_1: 'adult1', + ADULT_2: 'adult2', + CHILD_1: 'child1', + PERSON_NO_AGE: 'personNoAge', + PERSON_ORPHAN: 'orphanPerson', +} as const; + +// Countries +export const VALIDATION_COUNTRIES = { + US: 'us', + UK: 'uk', + CA: 'ca', + WRONG: 'wrong', +} as const; + +// Years +export const VALIDATION_YEARS = { + DEFAULT: '2024', + PAST: '2023', + FUTURE: '2025', + MISSING: '2026', +} as const; + +// Entity names +export const VALIDATION_ENTITY_NAMES = { + PEOPLE: 'people', + HOUSEHOLDS: 'households', + TAX_UNITS: 'taxUnits', + MARITAL_UNITS: 'maritalUnits', + BEN_UNITS: 'benunits', + FAMILIES: 'families', + SPM_UNITS: 'spmUnits', +} as const; + +// Group keys +export const VALIDATION_GROUP_KEYS = { + DEFAULT_HOUSEHOLD: 'household1', + DEFAULT_TAX_UNIT: 'taxUnit1', + DEFAULT_MARITAL_UNIT: 'maritalUnit1', + INVALID_MARITAL_UNIT: 'invalidMaritalUnit', + DEFAULT_BEN_UNIT: 'benUnit1', + EMPTY_BEN_UNIT: 'emptyBenUnit', +} as const; + +// Variable names +export const VALIDATION_VARIABLE_NAMES = { + EMPLOYMENT_INCOME: 'employment_income', + AGE: 'age', + IS_MARRIED: 'is_married', + STATE_CODE: 'state_code', + HOUSEHOLD_SIZE: 'household_size', +} as const; + +// Variable metadata +export const VALIDATION_VALUE_TYPES = { + FLOAT: 'float', + INT: 'int', + BOOL: 'bool', + STRING: 'string', +} as const; + +export const VALIDATION_DEFINITION_PERIODS = { + YEAR: 'year', + MONTH: 'month', + DAY: 'day', + ETERNITY: 'eternity', +} as const; + +// Test values +export const VALIDATION_TEST_VALUES = { + VALID_AGE: 30, + VALID_INCOME: 50000, + INVALID_STRING_FOR_NUMBER: 'not a number', + INVALID_NUMBER_FOR_BOOL: 123, + INVALID_BOOL_FOR_STRING: true, + VALID_FLOAT: 123.45, + INVALID_FLOAT_FOR_INT: 123.45, + VALID_INT: 123, + VALID_BOOL: true, + VALID_STRING: 'CA', +} as const; + +// ============= MOCK DATA OBJECTS ============= + +// Mock persons +export const mockPersonWithAge: HouseholdPerson = { + age: { [VALIDATION_YEARS.DEFAULT]: VALIDATION_TEST_VALUES.VALID_AGE }, +}; + +export const mockPersonNoAge: HouseholdPerson = { + employment_income: { [VALIDATION_YEARS.DEFAULT]: VALIDATION_TEST_VALUES.VALID_INCOME }, +}; + +export const mockPersonMissingYear: HouseholdPerson = { + age: { [VALIDATION_YEARS.PAST]: VALIDATION_TEST_VALUES.VALID_AGE }, +}; + +// Valid US household +export const mockValidUSHousehold: Household = { + countryId: VALIDATION_COUNTRIES.US, + householdData: { + people: { + [VALIDATION_PERSON_NAMES.ADULT_1]: mockPersonWithAge, + [VALIDATION_PERSON_NAMES.ADULT_2]: mockPersonWithAge, + }, + households: { + [VALIDATION_GROUP_KEYS.DEFAULT_HOUSEHOLD]: { + members: [VALIDATION_PERSON_NAMES.ADULT_1, VALIDATION_PERSON_NAMES.ADULT_2], + }, + }, + taxUnits: { + [VALIDATION_GROUP_KEYS.DEFAULT_TAX_UNIT]: { + members: [VALIDATION_PERSON_NAMES.ADULT_1, VALIDATION_PERSON_NAMES.ADULT_2], + }, + }, + }, +}; + +// US household with orphan person (not in tax unit) +export const mockUSHouseholdOrphanPerson: Household = { + countryId: VALIDATION_COUNTRIES.US, + householdData: { + people: { + [VALIDATION_PERSON_NAMES.ADULT_1]: mockPersonWithAge, + [VALIDATION_PERSON_NAMES.ADULT_2]: mockPersonWithAge, + [VALIDATION_PERSON_NAMES.PERSON_ORPHAN]: mockPersonWithAge, + }, + households: { + [VALIDATION_GROUP_KEYS.DEFAULT_HOUSEHOLD]: { + members: [ + VALIDATION_PERSON_NAMES.ADULT_1, + VALIDATION_PERSON_NAMES.ADULT_2, + VALIDATION_PERSON_NAMES.PERSON_ORPHAN, + ], + }, + }, + taxUnits: { + [VALIDATION_GROUP_KEYS.DEFAULT_TAX_UNIT]: { + members: [VALIDATION_PERSON_NAMES.ADULT_1, VALIDATION_PERSON_NAMES.ADULT_2], + }, + }, + }, +}; + +// US household with no tax units +export const mockUSHouseholdNoTaxUnits: Household = { + countryId: VALIDATION_COUNTRIES.US, + householdData: { + people: { + [VALIDATION_PERSON_NAMES.ADULT_1]: mockPersonWithAge, + }, + households: { + [VALIDATION_GROUP_KEYS.DEFAULT_HOUSEHOLD]: { + members: [VALIDATION_PERSON_NAMES.ADULT_1], + }, + }, + taxUnits: {}, + }, +}; + +// US household with invalid marital unit (1 person) +export const mockUSHouseholdInvalidMaritalUnit: Household = { + countryId: VALIDATION_COUNTRIES.US, + householdData: { + people: { + [VALIDATION_PERSON_NAMES.ADULT_1]: mockPersonWithAge, + }, + households: { + [VALIDATION_GROUP_KEYS.DEFAULT_HOUSEHOLD]: { + members: [VALIDATION_PERSON_NAMES.ADULT_1], + }, + }, + maritalUnits: { + [VALIDATION_GROUP_KEYS.INVALID_MARITAL_UNIT]: { + members: [VALIDATION_PERSON_NAMES.ADULT_1], + }, + }, + }, +}; + +// Valid UK household +export const mockValidUKHousehold: Household = { + countryId: VALIDATION_COUNTRIES.UK, + householdData: { + people: { + [VALIDATION_PERSON_NAMES.ADULT_1]: mockPersonWithAge, + [VALIDATION_PERSON_NAMES.CHILD_1]: mockPersonWithAge, + }, + households: { + [VALIDATION_GROUP_KEYS.DEFAULT_HOUSEHOLD]: { + members: [VALIDATION_PERSON_NAMES.ADULT_1, VALIDATION_PERSON_NAMES.CHILD_1], + }, + }, + benunits: { + [VALIDATION_GROUP_KEYS.DEFAULT_BEN_UNIT]: { + members: [VALIDATION_PERSON_NAMES.ADULT_1, VALIDATION_PERSON_NAMES.CHILD_1], + }, + }, + }, +}; + +// UK household with empty benefit unit +export const mockUKHouseholdEmptyBenUnit: Household = { + countryId: VALIDATION_COUNTRIES.UK, + householdData: { + people: { + [VALIDATION_PERSON_NAMES.ADULT_1]: mockPersonWithAge, + }, + households: { + [VALIDATION_GROUP_KEYS.DEFAULT_HOUSEHOLD]: { + members: [VALIDATION_PERSON_NAMES.ADULT_1], + }, + }, + benunits: { + [VALIDATION_GROUP_KEYS.DEFAULT_BEN_UNIT]: { + members: [VALIDATION_PERSON_NAMES.ADULT_1], + }, + [VALIDATION_GROUP_KEYS.EMPTY_BEN_UNIT]: { + members: [], + }, + }, + }, +}; + +// Household with country mismatch +export const mockHouseholdCountryMismatch: Household = { + countryId: VALIDATION_COUNTRIES.US, + householdData: { + people: { + [VALIDATION_PERSON_NAMES.ADULT_1]: mockPersonWithAge, + }, + households: { + [VALIDATION_GROUP_KEYS.DEFAULT_HOUSEHOLD]: { + members: [VALIDATION_PERSON_NAMES.ADULT_1], + }, + }, + }, +}; + +// Household with missing age +export const mockHouseholdMissingAge: Household = { + countryId: VALIDATION_COUNTRIES.US, + householdData: { + people: { + [VALIDATION_PERSON_NAMES.ADULT_1]: mockPersonWithAge, + [VALIDATION_PERSON_NAMES.PERSON_NO_AGE]: mockPersonNoAge, + }, + households: { + [VALIDATION_GROUP_KEYS.DEFAULT_HOUSEHOLD]: { + members: [VALIDATION_PERSON_NAMES.ADULT_1, VALIDATION_PERSON_NAMES.PERSON_NO_AGE], + }, + }, + }, +}; + +// Household with invalid group structure (missing members array) +export const mockHouseholdInvalidGroupStructure: Household = { + countryId: VALIDATION_COUNTRIES.US, + householdData: { + people: { + [VALIDATION_PERSON_NAMES.ADULT_1]: mockPersonWithAge, + }, + households: { + [VALIDATION_GROUP_KEYS.DEFAULT_HOUSEHOLD]: { + // Missing members array + } as any, + }, + }, +}; + +// Empty household +export const mockEmptyHousehold: Household = { + countryId: VALIDATION_COUNTRIES.US, + householdData: { + people: {}, + households: {}, + }, +}; + +// ============= MOCK VARIABLE METADATA ============= + +export const mockFloatMetadata: VariableMetadata = { + entity: VALIDATION_ENTITY_NAMES.PEOPLE, + valueType: VALIDATION_VALUE_TYPES.FLOAT as any, + definitionPeriod: VALIDATION_DEFINITION_PERIODS.YEAR as any, + name: VALIDATION_VARIABLE_NAMES.EMPLOYMENT_INCOME, + label: 'Employment Income', + unit: 'USD', + isInputVariable: true, + defaultValue: 0, +}; + +export const mockIntMetadata: VariableMetadata = { + entity: VALIDATION_ENTITY_NAMES.HOUSEHOLDS, + valueType: VALIDATION_VALUE_TYPES.INT as any, + definitionPeriod: VALIDATION_DEFINITION_PERIODS.YEAR as any, + name: VALIDATION_VARIABLE_NAMES.HOUSEHOLD_SIZE, + label: 'Household Size', + isInputVariable: false, + defaultValue: 1, +}; + +export const mockBoolMetadata: VariableMetadata = { + entity: VALIDATION_ENTITY_NAMES.PEOPLE, + valueType: VALIDATION_VALUE_TYPES.BOOL as any, + definitionPeriod: VALIDATION_DEFINITION_PERIODS.ETERNITY as any, + name: VALIDATION_VARIABLE_NAMES.IS_MARRIED, + label: 'Is Married', + isInputVariable: true, + defaultValue: false, +}; + +export const mockStringMetadata: VariableMetadata = { + entity: VALIDATION_ENTITY_NAMES.HOUSEHOLDS, + valueType: VALIDATION_VALUE_TYPES.STRING as any, + definitionPeriod: VALIDATION_DEFINITION_PERIODS.YEAR as any, + name: VALIDATION_VARIABLE_NAMES.STATE_CODE, + label: 'State Code', + isInputVariable: true, + defaultValue: '', +}; + +// ============= MOCK REDUX STATE ============= + +export const mockReduxStateWithMetadata: Partial = { + metadata: { + variables: { + [VALIDATION_VARIABLE_NAMES.EMPLOYMENT_INCOME]: { + entity: VALIDATION_ENTITY_NAMES.PEOPLE, + valueType: VALIDATION_VALUE_TYPES.FLOAT, + definitionPeriod: VALIDATION_DEFINITION_PERIODS.YEAR, + name: VALIDATION_VARIABLE_NAMES.EMPLOYMENT_INCOME, + label: 'Employment Income', + unit: 'USD', + isInputVariable: true, + defaultValue: 0, + }, + [VALIDATION_VARIABLE_NAMES.STATE_CODE]: { + entity: VALIDATION_ENTITY_NAMES.HOUSEHOLDS, + valueType: VALIDATION_VALUE_TYPES.STRING, + definitionPeriod: VALIDATION_DEFINITION_PERIODS.YEAR, + name: VALIDATION_VARIABLE_NAMES.STATE_CODE, + label: 'State Code', + isInputVariable: true, + defaultValue: '', + }, + }, + }, +} as any; + +export const mockReduxStateNoMetadata: Partial = {} as any; + +// ============= EXPECTED RESULTS ============= + +export const expectedValidResult: ValidationResult = { + isValid: true, + errors: [], + warnings: [], +}; + +export const expectedCountryMismatchError: ValidationError = { + code: VALIDATION_ERROR_CODES.COUNTRY_MISMATCH, + message: `Household country ${VALIDATION_COUNTRIES.US} does not match expected ${VALIDATION_COUNTRIES.UK}`, + field: 'countryId', +}; + +export const expectedMissingAgeWarning: ValidationWarning = { + code: VALIDATION_WARNING_CODES.MISSING_AGE, + message: `Person ${VALIDATION_PERSON_NAMES.PERSON_NO_AGE} is missing age for year ${VALIDATION_YEARS.DEFAULT}`, + field: `people.${VALIDATION_PERSON_NAMES.PERSON_NO_AGE}.age`, +}; + +export const expectedNoTaxUnitsWarning: ValidationWarning = { + code: VALIDATION_WARNING_CODES.NO_TAX_UNITS, + message: 'US households with people typically have at least one tax unit', + field: 'taxUnits', +}; + +export const expectedOrphanPersonWarning: ValidationWarning = { + code: VALIDATION_WARNING_CODES.PERSON_NOT_IN_TAX_UNIT, + message: `Person ${VALIDATION_PERSON_NAMES.PERSON_ORPHAN} is not assigned to any tax unit`, + field: `people.${VALIDATION_PERSON_NAMES.PERSON_ORPHAN}`, +}; + +export const expectedInvalidMaritalUnitError: ValidationError = { + code: VALIDATION_ERROR_CODES.INVALID_MARITAL_UNIT, + message: `Marital unit ${VALIDATION_GROUP_KEYS.INVALID_MARITAL_UNIT} must have exactly 2 members`, + field: `maritalUnits.${VALIDATION_GROUP_KEYS.INVALID_MARITAL_UNIT}`, +}; + +export const expectedEmptyBenUnitError: ValidationError = { + code: VALIDATION_ERROR_CODES.EMPTY_BENUNIT, + message: `Benefit unit ${VALIDATION_GROUP_KEYS.EMPTY_BEN_UNIT} has no members`, + field: `benunits.${VALIDATION_GROUP_KEYS.EMPTY_BEN_UNIT}`, +}; + +export const expectedInvalidGroupStructureError: ValidationError = { + code: VALIDATION_ERROR_CODES.INVALID_GROUP_STRUCTURE, + message: `Group ${VALIDATION_GROUP_KEYS.DEFAULT_HOUSEHOLD} in households must have a members array`, + field: `households.${VALIDATION_GROUP_KEYS.DEFAULT_HOUSEHOLD}.members`, +}; + +export const expectedNoPeopleError: ValidationError = { + code: VALIDATION_ERROR_CODES.NO_PEOPLE, + message: 'Household must have at least one person for simulation', + field: 'people', +}; + +// ============= TEST HELPERS ============= + +export const verifyValidationError = ( + errors: ValidationError[], + expectedCode: string, + expectedField?: string +): void => { + const error = errors.find((e) => e.code === expectedCode); + expect(error).toBeDefined(); + if (expectedField) { + expect(error?.field).toBe(expectedField); + } +}; + +export const verifyValidationWarning = ( + warnings: ValidationWarning[], + expectedCode: string, + expectedField?: string +): void => { + const warning = warnings.find((w) => w.code === expectedCode); + expect(warning).toBeDefined(); + if (expectedField) { + expect(warning?.field).toBe(expectedField); + } +}; + +export const verifyNoErrors = (result: ValidationResult): void => { + expect(result.errors).toHaveLength(0); + expect(result.isValid).toBe(true); +}; + +export const verifyHasErrors = (result: ValidationResult, errorCount: number): void => { + expect(result.errors).toHaveLength(errorCount); + expect(result.isValid).toBe(false); +}; + +export const verifyWarningCount = (result: ValidationResult, warningCount: number): void => { + expect(result.warnings).toHaveLength(warningCount); +}; diff --git a/app/src/tests/fixtures/utils/populationOpsMocks.ts b/app/src/tests/fixtures/utils/populationOpsMocks.ts new file mode 100644 index 00000000..245dd2b4 --- /dev/null +++ b/app/src/tests/fixtures/utils/populationOpsMocks.ts @@ -0,0 +1,261 @@ +import { vi } from 'vitest'; +import { + UserGeographyPopulation, + UserHouseholdPopulation, +} from '@/types/ingredients/UserPopulation'; +import { GeographyPopulationRef, HouseholdPopulationRef } from '@/utils/PopulationOps'; + +// ============= TEST CONSTANTS ============= + +// IDs +export const POPULATION_IDS = { + HOUSEHOLD_1: 'household-123', + HOUSEHOLD_2: 'household-456', + HOUSEHOLD_EMPTY: '', + GEOGRAPHY_1: 'us-ca', + GEOGRAPHY_2: 'uk-england', + GEOGRAPHY_EMPTY: '', + USER_1: 'user-123', + USER_2: 'user-456', +} as const; + +// Labels +export const POPULATION_LABELS = { + CUSTOM_LABEL: 'My Custom Population', + HOUSEHOLD_LABEL: 'My Household Configuration', + GEOGRAPHY_LABEL: 'California State', +} as const; + +// Countries +export const POPULATION_COUNTRIES = { + US: 'us', + UK: 'uk', + CA: 'ca', +} as const; + +// Scopes +export const POPULATION_SCOPES = { + NATIONAL: 'national', + SUBNATIONAL: 'subnational', +} as const; + +// Expected strings +export const EXPECTED_LABELS = { + HOUSEHOLD_DEFAULT: (id: string) => `Household ${id}`, + GEOGRAPHY_DEFAULT: (id: string) => `Geography: ${id}`, + HOUSEHOLD_TYPE: 'Household', + GEOGRAPHY_TYPE: 'Geography', + NATIONAL_PREFIX: 'National', + REGIONAL_PREFIX: 'Regional', +} as const; + +// Cache keys +export const EXPECTED_CACHE_KEYS = { + HOUSEHOLD: (id: string) => `household:${id}`, + GEOGRAPHY: (id: string) => `geography:${id}`, +} as const; + +// API payload keys +export const API_PAYLOAD_KEYS = { + POPULATION_ID: 'population_id', + HOUSEHOLD_ID: 'household_id', + GEOGRAPHY_ID: 'geography_id', + REGION: 'region', +} as const; + +// ============= MOCK DATA OBJECTS ============= + +// Household population references +export const mockHouseholdPopRef1: HouseholdPopulationRef = { + type: 'household', + householdId: POPULATION_IDS.HOUSEHOLD_1, +}; + +export const mockHouseholdPopRef2: HouseholdPopulationRef = { + type: 'household', + householdId: POPULATION_IDS.HOUSEHOLD_2, +}; + +export const mockHouseholdPopRefEmpty: HouseholdPopulationRef = { + type: 'household', + householdId: POPULATION_IDS.HOUSEHOLD_EMPTY, +}; + +// Geography population references +export const mockGeographyPopRef1: GeographyPopulationRef = { + type: 'geography', + geographyId: POPULATION_IDS.GEOGRAPHY_1, +}; + +export const mockGeographyPopRef2: GeographyPopulationRef = { + type: 'geography', + geographyId: POPULATION_IDS.GEOGRAPHY_2, +}; + +export const mockGeographyPopRefEmpty: GeographyPopulationRef = { + type: 'geography', + geographyId: POPULATION_IDS.GEOGRAPHY_EMPTY, +}; + +// User household populations +export const mockUserHouseholdPop: UserHouseholdPopulation = { + type: 'household', + householdId: POPULATION_IDS.HOUSEHOLD_1, + userId: POPULATION_IDS.USER_1, + label: POPULATION_LABELS.HOUSEHOLD_LABEL, + isCreated: true, +}; + +export const mockUserHouseholdPopNoLabel: UserHouseholdPopulation = { + type: 'household', + householdId: POPULATION_IDS.HOUSEHOLD_2, + userId: POPULATION_IDS.USER_2, +}; + +export const mockUserHouseholdPopInvalid: UserHouseholdPopulation = { + type: 'household', + householdId: POPULATION_IDS.HOUSEHOLD_EMPTY, + userId: POPULATION_IDS.USER_1, +}; + +export const mockUserHouseholdPopNoUser: UserHouseholdPopulation = { + type: 'household', + householdId: POPULATION_IDS.HOUSEHOLD_1, + userId: '', +}; + +// User geography populations +export const mockUserGeographyPop: UserGeographyPopulation = { + type: 'geography', + geographyId: POPULATION_IDS.GEOGRAPHY_1, + countryId: POPULATION_COUNTRIES.US, + scope: POPULATION_SCOPES.SUBNATIONAL as any, + userId: POPULATION_IDS.USER_1, + label: POPULATION_LABELS.GEOGRAPHY_LABEL, +}; + +export const mockUserGeographyPopNational: UserGeographyPopulation = { + type: 'geography', + geographyId: POPULATION_IDS.GEOGRAPHY_2, + countryId: POPULATION_COUNTRIES.UK, + scope: POPULATION_SCOPES.NATIONAL as any, + userId: POPULATION_IDS.USER_2, +}; + +export const mockUserGeographyPopInvalid: UserGeographyPopulation = { + type: 'geography', + geographyId: POPULATION_IDS.GEOGRAPHY_EMPTY, + countryId: POPULATION_COUNTRIES.US, + scope: POPULATION_SCOPES.NATIONAL as any, + userId: POPULATION_IDS.USER_1, +}; + +export const mockUserGeographyPopNoCountry: UserGeographyPopulation = { + type: 'geography', + geographyId: POPULATION_IDS.GEOGRAPHY_1, + countryId: '', + scope: POPULATION_SCOPES.NATIONAL as any, + userId: POPULATION_IDS.USER_1, +}; + +// ============= EXPECTED RESULTS ============= + +// Expected API payloads +export const expectedHouseholdAPIPayload = { + [API_PAYLOAD_KEYS.POPULATION_ID]: POPULATION_IDS.HOUSEHOLD_1, + [API_PAYLOAD_KEYS.HOUSEHOLD_ID]: POPULATION_IDS.HOUSEHOLD_1, +}; + +export const expectedGeographyAPIPayload = { + [API_PAYLOAD_KEYS.GEOGRAPHY_ID]: POPULATION_IDS.GEOGRAPHY_1, + [API_PAYLOAD_KEYS.REGION]: POPULATION_IDS.GEOGRAPHY_1, +}; + +// Expected labels +export const expectedHouseholdLabel = EXPECTED_LABELS.HOUSEHOLD_DEFAULT(POPULATION_IDS.HOUSEHOLD_1); +export const expectedGeographyLabel = EXPECTED_LABELS.GEOGRAPHY_DEFAULT(POPULATION_IDS.GEOGRAPHY_1); + +// Expected cache keys +export const expectedHouseholdCacheKey = EXPECTED_CACHE_KEYS.HOUSEHOLD(POPULATION_IDS.HOUSEHOLD_1); +export const expectedGeographyCacheKey = EXPECTED_CACHE_KEYS.GEOGRAPHY(POPULATION_IDS.GEOGRAPHY_1); + +// Expected user population labels +export const expectedUserHouseholdLabel = POPULATION_LABELS.HOUSEHOLD_LABEL; +export const expectedUserHouseholdDefaultLabel = EXPECTED_LABELS.HOUSEHOLD_DEFAULT( + POPULATION_IDS.HOUSEHOLD_2 +); +export const expectedUserGeographyLabel = POPULATION_LABELS.GEOGRAPHY_LABEL; +export const expectedUserGeographyNationalLabel = `${EXPECTED_LABELS.NATIONAL_PREFIX}: ${POPULATION_IDS.GEOGRAPHY_2}`; +export const expectedUserGeographyRegionalLabel = `${EXPECTED_LABELS.REGIONAL_PREFIX}: ${POPULATION_IDS.GEOGRAPHY_1}`; + +// ============= TEST HELPERS ============= + +// Helper to create a household population ref +export const createHouseholdPopRef = (householdId: string): HouseholdPopulationRef => ({ + type: 'household', + householdId, +}); + +// Helper to create a geography population ref +export const createGeographyPopRef = (geographyId: string): GeographyPopulationRef => ({ + type: 'geography', + geographyId, +}); + +// Helper to create a user household population +export const createUserHouseholdPop = ( + householdId: string, + userId: string, + label?: string +): UserHouseholdPopulation => ({ + type: 'household', + householdId, + userId, + ...(label && { label }), +}); + +// Helper to create a user geography population +export const createUserGeographyPop = ( + geographyId: string, + countryId: string, + scope: 'national' | 'subnational', + userId: string, + label?: string +): UserGeographyPopulation => ({ + type: 'geography', + geographyId, + countryId, + scope, + userId, + ...(label && { label }), +}); + +// Helper to verify API payload +export const verifyAPIPayload = ( + payload: Record, + expectedKeys: string[], + expectedValues: Record +): void => { + expectedKeys.forEach((key) => { + expect(payload).toHaveProperty(key); + expect(payload[key]).toBe(expectedValues[key]); + }); +}; + +// Mock handler functions for testing pattern matching +export const mockHandlers = { + household: vi.fn(), + geography: vi.fn(), +}; + +// Helper to reset mock handlers +export const resetMockHandlers = (): void => { + mockHandlers.household.mockReset(); + mockHandlers.geography.mockReset(); +}; + +// Helper to setup mock handler returns +export const setupMockHandlerReturns = (householdReturn: T, geographyReturn: T): void => { + mockHandlers.household.mockReturnValue(householdReturn); + mockHandlers.geography.mockReturnValue(geographyReturn); +}; diff --git a/app/src/tests/unit/adapters/HouseholdAdapter.test.ts b/app/src/tests/unit/adapters/HouseholdAdapter.test.ts new file mode 100644 index 00000000..2546a231 --- /dev/null +++ b/app/src/tests/unit/adapters/HouseholdAdapter.test.ts @@ -0,0 +1,288 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { HouseholdAdapter } from '@/adapters/HouseholdAdapter'; +import { store } from '@/store'; +import { + mockEmptyHouseholdData, + mockEntityMetadata, + mockHouseholdData, + mockHouseholdDataWithMultipleEntities, + mockHouseholdDataWithUnknownEntity, + mockHouseholdMetadata, + mockHouseholdMetadataWithUnknownEntity, +} from '@/tests/fixtures/adapters/HouseholdAdapterMocks'; + +vi.mock('@/store', () => ({ + store: { + getState: vi.fn(), + }, +})); + +describe('HouseholdAdapter', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + (store.getState as any).mockReturnValue({ + metadata: { + entities: mockEntityMetadata, + }, + }); + }); + + describe('fromAPI', () => { + test('given valid household metadata from API then converts to internal Household format', () => { + const result = HouseholdAdapter.fromAPI(mockHouseholdMetadata); + + expect(result).toEqual({ + id: '12345', + countryId: 'us', + householdData: { + people: mockHouseholdMetadata.household_json.people, + taxUnits: mockHouseholdMetadata.household_json.tax_units, + maritalUnits: mockHouseholdMetadata.household_json.marital_units, + spmUnits: mockHouseholdMetadata.household_json.spm_units, + households: mockHouseholdMetadata.household_json.households, + families: mockHouseholdMetadata.household_json.families, + }, + }); + }); + + test('given API response with snake_case entities then converts all to camelCase', () => { + const metadata = { + id: 123, + country_id: 'uk', + household_json: { + people: { person1: { age: { 2024: 30 } } }, + tax_units: { unit1: { members: ['person1'] } }, + marital_units: { unit1: { members: ['person1'] } }, + }, + }; + + const result = HouseholdAdapter.fromAPI(metadata as any); + + expect(result.householdData).toHaveProperty('taxUnits'); + expect(result.householdData).toHaveProperty('maritalUnits'); + expect(result.householdData.taxUnits).toEqual(metadata.household_json.tax_units); + expect(result.householdData.maritalUnits).toEqual(metadata.household_json.marital_units); + }); + + test('given entity not in metadata then logs warning but includes it anyway', () => { + const result = HouseholdAdapter.fromAPI(mockHouseholdMetadataWithUnknownEntity); + + expect(console.warn).toHaveBeenCalledWith( + 'Entity "unknown_entity" not found in metadata, including anyway' + ); + expect(result.householdData).toHaveProperty('unknownEntity'); + expect(result.householdData.unknownEntity).toEqual( + // @ts-expect-error + mockHouseholdMetadataWithUnknownEntity.household_json.unknown_entity + ); + }); + + test('given people entity then always includes it without validation', () => { + const metadata = { + id: 456, + country_id: 'us', + household_json: { + people: { + person1: { age: { 2024: 25 } }, + person2: { age: { 2024: 30 } }, + }, + }, + }; + + const result = HouseholdAdapter.fromAPI(metadata as any); + + expect(result.householdData.people).toEqual(metadata.household_json.people); + expect(console.warn).not.toHaveBeenCalled(); + }); + + test('given empty household_json except people then returns only people', () => { + const metadata = { + id: 789, + country_id: 'ca', + household_json: { + people: {}, + }, + }; + + const result = HouseholdAdapter.fromAPI(metadata as any); + + expect(result).toEqual({ + id: 789, + countryId: 'ca', + householdData: { + people: {}, + }, + }); + }); + }); + + describe('toCreationPayload', () => { + test('given household data then creates proper payload structure', () => { + const result = HouseholdAdapter.toCreationPayload(mockHouseholdData, 'us'); + + expect(result).toEqual({ + country_id: 'us', + data: { + people: mockHouseholdData.people, + tax_units: mockHouseholdData.taxUnits, + marital_units: mockHouseholdData.maritalUnits, + }, + }); + }); + + test('given household data with tax_units then converts to snake_case in payload', () => { + const householdData = { + people: { person1: { age: { 2024: 30 } } }, + taxUnits: { unit1: { members: ['person1'] } }, + }; + + const result = HouseholdAdapter.toCreationPayload(householdData as any, 'uk'); + + expect(result.data).toHaveProperty('tax_units'); + expect(result.data.tax_units).toEqual(householdData.taxUnits); + }); + + test('given camelCase taxUnits then converts to tax_units in payload', () => { + const householdData = { + people: {}, + taxUnits: { unit1: { head: 'person1' } }, + }; + + const result = HouseholdAdapter.toCreationPayload(householdData as any, 'us'); + + expect(result.data).toHaveProperty('tax_units'); + expect(result.data).not.toHaveProperty('taxUnits'); + }); + + test('given household data with multiple entities then payload includes all entities', () => { + const result = HouseholdAdapter.toCreationPayload( + mockHouseholdDataWithMultipleEntities, + 'us' + ); + + expect(result.data).toHaveProperty('people'); + expect(result.data).toHaveProperty('tax_units'); + expect(result.data).toHaveProperty('marital_units'); + expect(result.data).toHaveProperty('spm_units'); + }); + + test('given empty household data then creates minimal payload with only people', () => { + const result = HouseholdAdapter.toCreationPayload(mockEmptyHouseholdData, 'ca'); + + expect(result).toEqual({ + country_id: 'ca', + data: { + people: {}, + }, + }); + }); + + test('given entity not in metadata then toCreationPayload logs warning and uses snake_case', () => { + const result = HouseholdAdapter.toCreationPayload(mockHouseholdDataWithUnknownEntity, 'uk'); + + expect(console.warn).toHaveBeenCalledWith( + 'Entity "customEntity" not found in metadata, using snake_case "custom_entity"' + ); + expect(result.data).toHaveProperty('custom_entity'); + // @ts-expect-error + expect(result.data.custom_entity).toEqual(mockHouseholdDataWithUnknownEntity.customEntity); + }); + + test('given people entity then treats it as special case without conversion', () => { + const householdData = { + people: { + person1: { age: { 2024: 40 } }, + person2: { age: { 2024: 35 } }, + }, + }; + + const result = HouseholdAdapter.toCreationPayload(householdData as any, 'us'); + + expect(result.data.people).toEqual(householdData.people); + expect(console.warn).not.toHaveBeenCalled(); + }); + + test('given entity with matching plural in metadata then uses metadata plural form', () => { + const householdData = { + people: {}, + maritalUnits: { unit1: { members: ['person1', 'person2'] } }, + }; + + const result = HouseholdAdapter.toCreationPayload(householdData as any, 'uk'); + + expect(result.data).toHaveProperty('marital_units'); + expect(result.data.marital_units).toEqual(householdData.maritalUnits); + }); + }); + + describe('Edge cases and error handling', () => { + test('given metadata with no entities then still processes people', () => { + (store.getState as any).mockReturnValue({ + metadata: { entities: {} }, + }); + + const metadata = { + id: 999, + country_id: 'us', + household_json: { + people: { person1: { age: { 2024: 50 } } }, + }, + }; + + const result = HouseholdAdapter.fromAPI(metadata as any); + + expect(result.householdData.people).toEqual(metadata.household_json.people); + }); + + test('given undefined metadata entities then handles gracefully', () => { + (store.getState as any).mockReturnValue({ + metadata: {}, + }); + + const metadata = { + id: 111, + country_id: 'ca', + household_json: { + people: { person1: {} }, + tax_units: { unit1: {} }, + }, + }; + + const result = HouseholdAdapter.fromAPI(metadata as any); + + expect(result.householdData.people).toBeDefined(); + expect(console.warn).toHaveBeenCalled(); + }); + + test('given complex nested snake_case then converts correctly to camelCase', () => { + const metadata = { + id: 222, + country_id: 'uk', + household_json: { + people: {}, + very_long_entity_name: { data: 'test' }, + }, + }; + + const result = HouseholdAdapter.fromAPI(metadata as any); + + expect(result.householdData).toHaveProperty('veryLongEntityName'); + expect(result.householdData.veryLongEntityName).toEqual({ data: 'test' }); + }); + + test('given complex camelCase then converts correctly to snake_case', () => { + const householdData = { + people: {}, + veryLongEntityName: { data: 'test' }, + }; + + const result = HouseholdAdapter.toCreationPayload(householdData as any, 'us'); + + expect(result.data).toHaveProperty('very_long_entity_name'); + // @ts-expect-error + expect(result.data.very_long_entity_name).toEqual({ data: 'test' }); + }); + }); +}); diff --git a/app/src/tests/unit/api/household.test.ts b/app/src/tests/unit/api/household.test.ts new file mode 100644 index 00000000..991be64c --- /dev/null +++ b/app/src/tests/unit/api/household.test.ts @@ -0,0 +1,198 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { createHousehold, fetchHouseholdById } from '@/api/household'; +import { BASE_URL } from '@/constants'; +import { + ERROR_MESSAGES, + EXISTING_HOUSEHOLD_ID, + HTTP_STATUS, + mockCreateHouseholdResponse, + mockErrorResponse, + mockFetchError, + mockHouseholdCreationPayload, + mockHouseholdCreationPayloadUK, + mockHouseholdMetadata, + mockLargeHouseholdPayload, + mockNetworkError, + mockSuccessResponse, + NON_EXISTENT_HOUSEHOLD_ID, + TEST_COUNTRIES, +} from '@/tests/fixtures/api/householdMocks'; + +global.fetch = vi.fn(); + +describe('household API', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('fetchHouseholdById', () => { + test('given valid household ID then fetches household metadata successfully', async () => { + // Given + const country = TEST_COUNTRIES.US; + const householdId = EXISTING_HOUSEHOLD_ID; + const mockResponse = mockSuccessResponse({ result: mockHouseholdMetadata }); + (global.fetch as any).mockResolvedValue(mockResponse); + + // When + const result = await fetchHouseholdById(country, householdId); + + // Then + expect(global.fetch).toHaveBeenCalledWith(`${BASE_URL}/${country}/household/${householdId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }); + expect(result).toEqual(mockHouseholdMetadata); + }); + + test('given API returns error then throws error with household ID', async () => { + // Given + const country = TEST_COUNTRIES.UK; + const householdId = NON_EXISTENT_HOUSEHOLD_ID; + const mockResponse = mockErrorResponse(HTTP_STATUS.NOT_FOUND); + (global.fetch as any).mockResolvedValue(mockResponse); + + // When/Then + await expect(fetchHouseholdById(country, householdId)).rejects.toThrow( + ERROR_MESSAGES.FETCH_HOUSEHOLD_FAILED(householdId) + ); + }); + + test('given different country codes then constructs correct URL', async () => { + // Given + const countries = Object.values(TEST_COUNTRIES); + const householdId = EXISTING_HOUSEHOLD_ID; + const mockResponse = mockSuccessResponse({ result: mockHouseholdMetadata }); + (global.fetch as any).mockResolvedValue(mockResponse); + + // When + for (const country of countries) { + await fetchHouseholdById(country, householdId); + } + + // Then + expect(global.fetch).toHaveBeenNthCalledWith( + 1, + `${BASE_URL}/${TEST_COUNTRIES.US}/household/${householdId}`, + expect.any(Object) + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 2, + `${BASE_URL}/${TEST_COUNTRIES.UK}/household/${householdId}`, + expect.any(Object) + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 3, + `${BASE_URL}/${TEST_COUNTRIES.CA}/household/${householdId}`, + expect.any(Object) + ); + }); + + test('given network error then propagates error', async () => { + // Given + const country = TEST_COUNTRIES.US; + const householdId = EXISTING_HOUSEHOLD_ID; + (global.fetch as any).mockRejectedValue(mockNetworkError); + + // When/Then + await expect(fetchHouseholdById(country, householdId)).rejects.toThrow( + ERROR_MESSAGES.NETWORK_ERROR + ); + }); + }); + + describe('createHousehold', () => { + test('given valid household data then creates household successfully', async () => { + // Given + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue(mockCreateHouseholdResponse), + }; + (global.fetch as any).mockResolvedValue(mockResponse); + + // When + const result = await createHousehold(mockHouseholdCreationPayload); + + // Then + expect(global.fetch).toHaveBeenCalledWith( + `${BASE_URL}/${mockHouseholdCreationPayload.country_id}/household`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(mockHouseholdCreationPayload), + } + ); + expect(result).toEqual(mockCreateHouseholdResponse); + }); + + test('given API returns error then throws error', async () => { + // Given + const mockResponse = mockErrorResponse(HTTP_STATUS.BAD_REQUEST); + (global.fetch as any).mockResolvedValue(mockResponse); + + // When/Then + await expect(createHousehold(mockHouseholdCreationPayload)).rejects.toThrow( + ERROR_MESSAGES.CREATE_HOUSEHOLD_FAILED + ); + }); + + test('given different country in payload then uses correct URL', async () => { + // Given + const mockResponse = mockSuccessResponse(mockCreateHouseholdResponse); + (global.fetch as any).mockResolvedValue(mockResponse); + + // When + await createHousehold(mockHouseholdCreationPayloadUK); + + // Then + expect(global.fetch).toHaveBeenCalledWith( + `${BASE_URL}/${TEST_COUNTRIES.UK}/household`, + expect.any(Object) + ); + }); + + test('given server error then throws generic error message', async () => { + // Given + const mockResponse = mockErrorResponse(HTTP_STATUS.INTERNAL_SERVER_ERROR); + (global.fetch as any).mockResolvedValue(mockResponse); + + // When/Then + await expect(createHousehold(mockHouseholdCreationPayload)).rejects.toThrow( + ERROR_MESSAGES.CREATE_HOUSEHOLD_FAILED + ); + }); + + test('given network failure then propagates network error', async () => { + // Given + (global.fetch as any).mockRejectedValue(mockFetchError); + + // When/Then + await expect(createHousehold(mockHouseholdCreationPayload)).rejects.toThrow( + ERROR_MESSAGES.FAILED_TO_FETCH + ); + }); + + test('given large payload then sends complete JSON body', async () => { + // Given + const mockResponse = mockSuccessResponse(mockCreateHouseholdResponse); + (global.fetch as any).mockResolvedValue(mockResponse); + + // When + await createHousehold(mockLargeHouseholdPayload); + + // Then + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify(mockLargeHouseholdPayload), + }) + ); + }); + }); +}); diff --git a/app/src/tests/unit/api/householdAssociation.test.ts b/app/src/tests/unit/api/householdAssociation.test.ts new file mode 100644 index 00000000..d4750d1b --- /dev/null +++ b/app/src/tests/unit/api/householdAssociation.test.ts @@ -0,0 +1,419 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { UserHouseholdAdapter } from '@/adapters/UserHouseholdAdapter'; +import { ApiHouseholdStore, SessionStorageHouseholdStore } from '@/api/householdAssociation'; +import { + mockApiResponse, + mockApiResponseList, + mockCreationPayload, + mockUserHouseholdPopulation, + mockUserHouseholdPopulationList, +} from '@/tests/fixtures/api/householdAssociationMocks'; + +global.fetch = vi.fn(); + +vi.mock('@/adapters/UserHouseholdAdapter', () => ({ + UserHouseholdAdapter: { + toCreationPayload: vi.fn(), + fromApiResponse: vi.fn(), + }, +})); + +describe('ApiHouseholdStore', () => { + let store: ApiHouseholdStore; + + beforeEach(() => { + vi.clearAllMocks(); + store = new ApiHouseholdStore(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('create', () => { + test('given valid household association then creates successfully', async () => { + // Given + (UserHouseholdAdapter.toCreationPayload as any).mockReturnValue(mockCreationPayload); + (UserHouseholdAdapter.fromApiResponse as any).mockReturnValue(mockUserHouseholdPopulation); + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue(mockApiResponse), + }; + (global.fetch as any).mockResolvedValue(mockResponse); + + // When + const result = await store.create(mockUserHouseholdPopulation); + + // Then + expect(UserHouseholdAdapter.toCreationPayload).toHaveBeenCalledWith( + mockUserHouseholdPopulation + ); + expect(global.fetch).toHaveBeenCalledWith('/api/user-household-associations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(mockCreationPayload), + }); + expect(UserHouseholdAdapter.fromApiResponse).toHaveBeenCalledWith(mockApiResponse); + expect(result).toEqual(mockUserHouseholdPopulation); + }); + + test('given API returns error then throws error', async () => { + // Given + (UserHouseholdAdapter.toCreationPayload as any).mockReturnValue(mockCreationPayload); + const mockResponse = { + ok: false, + status: 400, + }; + (global.fetch as any).mockResolvedValue(mockResponse); + + // When/Then + await expect(store.create(mockUserHouseholdPopulation)).rejects.toThrow( + 'Failed to create household association' + ); + }); + + test('given network error then propagates error', async () => { + // Given + (UserHouseholdAdapter.toCreationPayload as any).mockReturnValue(mockCreationPayload); + const networkError = new Error('Network failure'); + (global.fetch as any).mockRejectedValue(networkError); + + // When/Then + await expect(store.create(mockUserHouseholdPopulation)).rejects.toThrow('Network failure'); + }); + }); + + describe('findByUser', () => { + test('given valid user ID then returns list of households', async () => { + // Given + const userId = 'user-456'; + (UserHouseholdAdapter.fromApiResponse as any).mockImplementation((data: any) => { + const index = mockApiResponseList.indexOf(data); + return mockUserHouseholdPopulationList[index]; + }); + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue(mockApiResponseList), + }; + (global.fetch as any).mockResolvedValue(mockResponse); + + // When + const result = await store.findByUser(userId); + + // Then + expect(global.fetch).toHaveBeenCalledWith(`/api/user-household-associations/user/${userId}`); + expect(UserHouseholdAdapter.fromApiResponse).toHaveBeenCalledTimes(2); + expect(result).toEqual(mockUserHouseholdPopulationList); + }); + + test('given user with no households then returns empty array', async () => { + // Given + const userId = 'user-with-no-households'; + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue([]), + }; + (global.fetch as any).mockResolvedValue(mockResponse); + + // When + const result = await store.findByUser(userId); + + // Then + expect(result).toEqual([]); + expect(UserHouseholdAdapter.fromApiResponse).not.toHaveBeenCalled(); + }); + + test('given API returns error then throws error', async () => { + // Given + const userId = 'user-456'; + const mockResponse = { + ok: false, + status: 500, + }; + (global.fetch as any).mockResolvedValue(mockResponse); + + // When/Then + await expect(store.findByUser(userId)).rejects.toThrow('Failed to fetch user households'); + }); + }); + + describe('findById', () => { + test('given valid user and household IDs then returns household', async () => { + // Given + const userId = 'user-456'; + const householdId = 'household-123'; + (UserHouseholdAdapter.fromApiResponse as any).mockReturnValue(mockUserHouseholdPopulation); + const mockResponse = { + ok: true, + status: 200, + json: vi.fn().mockResolvedValue(mockApiResponse), + }; + (global.fetch as any).mockResolvedValue(mockResponse); + + // When + const result = await store.findById(userId, householdId); + + // Then + expect(global.fetch).toHaveBeenCalledWith( + `/api/user-household-associations/${userId}/${householdId}` + ); + expect(UserHouseholdAdapter.fromApiResponse).toHaveBeenCalledWith(mockApiResponse); + expect(result).toEqual(mockUserHouseholdPopulation); + }); + + test('given non-existent association then returns null', async () => { + // Given + const userId = 'user-456'; + const householdId = 'non-existent'; + const mockResponse = { + ok: false, + status: 404, + }; + (global.fetch as any).mockResolvedValue(mockResponse); + + // When + const result = await store.findById(userId, householdId); + + // Then + expect(result).toBeNull(); + expect(UserHouseholdAdapter.fromApiResponse).not.toHaveBeenCalled(); + }); + + test('given server error then throws error', async () => { + // Given + const userId = 'user-456'; + const householdId = 'household-123'; + const mockResponse = { + ok: false, + status: 500, + }; + (global.fetch as any).mockResolvedValue(mockResponse); + + // When/Then + await expect(store.findById(userId, householdId)).rejects.toThrow( + 'Failed to fetch association' + ); + }); + }); +}); + +describe('SessionStorageHouseholdStore', () => { + let store: SessionStorageHouseholdStore; + let mockSessionStorage: { [key: string]: string }; + + beforeEach(() => { + vi.clearAllMocks(); + mockSessionStorage = {}; + + // Mock sessionStorage + Object.defineProperty(window, 'sessionStorage', { + value: { + getItem: vi.fn((key) => mockSessionStorage[key] || null), + setItem: vi.fn((key, value) => { + mockSessionStorage[key] = value; + }), + removeItem: vi.fn((key) => { + delete mockSessionStorage[key]; + }), + clear: vi.fn(() => { + mockSessionStorage = {}; + }), + }, + writable: true, + }); + + store = new SessionStorageHouseholdStore(); + }); + + describe('create', () => { + test('given new household association then stores in session storage', async () => { + // Given + const household = { ...mockUserHouseholdPopulation }; + delete (household as any).createdAt; // Test that createdAt is generated + + // When + const result = await store.create(household); + + // Then + expect(result).toMatchObject({ + ...household, + type: 'household', + id: household.householdId, + isCreated: true, + }); + expect(result.createdAt).toBeDefined(); + expect(sessionStorage.setItem).toHaveBeenCalledWith( + 'user-population-households', + expect.any(String) + ); + }); + + test('given duplicate association then throws error', async () => { + // Given + mockSessionStorage['user-population-households'] = JSON.stringify([ + mockUserHouseholdPopulation, + ]); + + // When/Then + await expect(store.create(mockUserHouseholdPopulation)).rejects.toThrow( + 'Association already exists' + ); + }); + + test('given existing households then appends new household', async () => { + // Given + const existingHousehold = mockUserHouseholdPopulationList[0]; + mockSessionStorage['user-population-households'] = JSON.stringify([existingHousehold]); + const newHousehold = { + ...mockUserHouseholdPopulation, + householdId: 'new-household', + id: 'new-household', + }; + + // When + await store.create(newHousehold); + + // Then + const stored = JSON.parse(mockSessionStorage['user-population-households']); + expect(stored).toHaveLength(2); + expect(stored[1].householdId).toBe('new-household'); + }); + + test('given storage failure then throws error', async () => { + // Given + (sessionStorage.setItem as any).mockImplementation(() => { + throw new Error('QuotaExceededError'); + }); + + // When/Then + await expect(store.create(mockUserHouseholdPopulation)).rejects.toThrow( + 'Failed to store households in session storage' + ); + }); + }); + + describe('findByUser', () => { + test('given user with households then returns filtered list', async () => { + // Given + const userId = 'user-456'; + const otherUserHousehold = { + ...mockUserHouseholdPopulation, + userId: 'other-user', + householdId: 'other-household', + }; + mockSessionStorage['user-population-households'] = JSON.stringify([ + ...mockUserHouseholdPopulationList, + otherUserHousehold, + ]); + + // When + const result = await store.findByUser(userId); + + // Then + expect(result).toHaveLength(2); + expect(result).toEqual(mockUserHouseholdPopulationList); + }); + + test('given user with no households then returns empty array', async () => { + // Given + mockSessionStorage['user-population-households'] = JSON.stringify( + mockUserHouseholdPopulationList + ); + + // When + const result = await store.findByUser('user-with-no-households'); + + // Then + expect(result).toEqual([]); + }); + + test('given empty storage then returns empty array', async () => { + // When + const result = await store.findByUser('any-user'); + + // Then + expect(result).toEqual([]); + }); + + test('given corrupted storage data then returns empty array', async () => { + // Given + mockSessionStorage['user-population-households'] = 'invalid-json'; + + // When + const result = await store.findByUser('user-456'); + + // Then + expect(result).toEqual([]); + }); + }); + + describe('findById', () => { + test('given existing association then returns household', async () => { + // Given + mockSessionStorage['user-population-households'] = JSON.stringify( + mockUserHouseholdPopulationList + ); + + // When + const result = await store.findById('user-456', 'household-1'); + + // Then + expect(result).toEqual(mockUserHouseholdPopulationList[0]); + }); + + test('given non-existent association then returns null', async () => { + // Given + mockSessionStorage['user-population-households'] = JSON.stringify( + mockUserHouseholdPopulationList + ); + + // When + const result = await store.findById('user-456', 'non-existent'); + + // Then + expect(result).toBeNull(); + }); + + test('given wrong user ID then returns null', async () => { + // Given + mockSessionStorage['user-population-households'] = JSON.stringify( + mockUserHouseholdPopulationList + ); + + // When + const result = await store.findById('wrong-user', 'household-1'); + + // Then + expect(result).toBeNull(); + }); + }); + + describe('utility methods', () => { + test('given households in storage then getAllAssociations returns all', () => { + // Given + mockSessionStorage['user-population-households'] = JSON.stringify( + mockUserHouseholdPopulationList + ); + + // When + const result = store.getAllAssociations(); + + // Then + expect(result).toEqual(mockUserHouseholdPopulationList); + }); + + test('given households in storage then clearAllAssociations removes them', () => { + // Given + mockSessionStorage['user-population-households'] = JSON.stringify( + mockUserHouseholdPopulationList + ); + + // When + store.clearAllAssociations(); + + // Then + expect(sessionStorage.removeItem).toHaveBeenCalledWith('user-population-households'); + expect(mockSessionStorage['user-population-households']).toBeUndefined(); + }); + }); +}); diff --git a/app/src/tests/unit/frames/population/GeographicConfirmationFrame.test.tsx b/app/src/tests/unit/frames/population/GeographicConfirmationFrame.test.tsx new file mode 100644 index 00000000..5012b076 --- /dev/null +++ b/app/src/tests/unit/frames/population/GeographicConfirmationFrame.test.tsx @@ -0,0 +1,361 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { screen, waitFor } from '@test-utils'; +import { render } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { MantineProvider } from '@mantine/core'; +import GeographicConfirmationFrame from '@/frames/population/GeographicConfirmationFrame'; +import metadataReducer from '@/reducers/metadataReducer'; +import populationReducer from '@/reducers/populationReducer'; +import { + mockFlowProps, + mockGeographicAssociation, + mockNationalGeography, + mockStateGeography, + TEST_COUNTRIES, + TEST_USER_ID, +} from '@/tests/fixtures/frames/populationMocks'; + +// Mock the regions data +vi.mock('@/mocks/regions', () => ({ + us_regions: { + result: { + economy_options: { + region: [ + { name: 'us', label: 'United States' }, + { name: 'state/ca', label: 'California' }, + { name: 'state/ny', label: 'New York' }, + ], + }, + }, + }, + uk_regions: { + result: { + economy_options: { + region: [ + { name: 'uk', label: 'United Kingdom' }, + { name: 'constituency/london', label: 'London' }, + ], + }, + }, + }, +})); + +// Mock constants +vi.mock('@/constants', () => ({ + MOCK_USER_ID: 'test-user-123', +})); + +// Mock hooks +const mockCreateGeographicAssociation = vi.fn(); +const mockResetIngredient = vi.fn(); + +vi.mock('@/hooks/useUserGeographic', () => ({ + useCreateGeographicAssociation: () => ({ + mutateAsync: mockCreateGeographicAssociation, + isPending: false, + }), +})); + +vi.mock('@/hooks/useIngredientReset', () => ({ + useIngredientReset: () => ({ + resetIngredient: mockResetIngredient, + }), +})); + +describe('GeographicConfirmationFrame', () => { + let store: any; + + beforeEach(() => { + vi.clearAllMocks(); + mockCreateGeographicAssociation.mockResolvedValue(mockGeographicAssociation); + }); + + const renderComponent = ( + populationState: any = {}, + metadataState: Partial = { currentCountry: TEST_COUNTRIES.US }, + props = mockFlowProps + ) => { + const fullMetadataState = { + loading: false, + error: null, + currentCountry: TEST_COUNTRIES.US as string, + variables: {}, + parameters: {}, + entities: {}, + variableModules: {}, + economyOptions: { region: [], time_period: [], datasets: [] }, + currentLawId: 0, + basicInputs: [], + modelledPolicies: { core: {}, filtered: {} }, + version: null, + parameterTree: null, + ...metadataState, + }; + + store = configureStore({ + reducer: { + population: populationReducer, + metadata: metadataReducer, + }, + preloadedState: { + population: populationState, + metadata: fullMetadataState, + }, + }); + + return render( + + + + + + ); + }; + + describe('National geography', () => { + test('given national geography then displays correct country information', () => { + // Given + const populationState = { + geography: mockNationalGeography, + }; + + // When + renderComponent(populationState); + + // Then + expect(screen.getByText('Confirm Geographic Selection')).toBeInTheDocument(); + expect(screen.getByText('National')).toBeInTheDocument(); + expect(screen.getByText('United States')).toBeInTheDocument(); + }); + + test('given UK national geography then displays United Kingdom', () => { + // Given + const populationState = { + geography: { + ...mockNationalGeography, + id: TEST_COUNTRIES.UK, + countryId: TEST_COUNTRIES.UK, + geographyId: TEST_COUNTRIES.UK, + }, + }; + + // When + renderComponent(populationState, { currentCountry: TEST_COUNTRIES.UK }); + + // Then + expect(screen.getByText('United Kingdom')).toBeInTheDocument(); + }); + }); + + describe('Subnational geography', () => { + test('given state geography then displays state information', () => { + // Given + const populationState = { + geography: mockStateGeography, + }; + + // When + renderComponent(populationState); + + // Then + expect(screen.getByText('Confirm Geographic Selection')).toBeInTheDocument(); + expect(screen.getByText('State')).toBeInTheDocument(); + expect(screen.getByText('California')).toBeInTheDocument(); + }); + + test('given UK constituency then displays constituency information', () => { + // Given + const populationState = { + geography: { + id: `${TEST_COUNTRIES.UK}-london`, + countryId: TEST_COUNTRIES.UK, + scope: 'subnational', + geographyId: 'london', + }, + }; + + // When + renderComponent(populationState, { currentCountry: TEST_COUNTRIES.UK }); + + // Then + expect(screen.getByText('Constituency')).toBeInTheDocument(); + expect(screen.getByText('London')).toBeInTheDocument(); + }); + }); + + describe('Submission handling', () => { + test('given valid national geography when submitted then creates association and updates state', async () => { + // Given + const populationState = { + geography: mockNationalGeography, + label: 'Test National', + }; + const props = { ...mockFlowProps }; + renderComponent(populationState, undefined, props); + + // When + const submitButton = screen.getByRole('button', { name: /Create Geographic Association/i }); + submitButton.click(); + + // Then + await waitFor(() => { + expect(mockCreateGeographicAssociation).toHaveBeenCalledWith( + expect.objectContaining({ + userId: TEST_USER_ID, + countryCode: TEST_COUNTRIES.US, + geographyType: 'national', + geographyIdentifier: TEST_COUNTRIES.US, + label: 'United States', + }) + ); + }); + + await waitFor(() => { + expect(props.onReturn).toHaveBeenCalled(); + }); + }); + + test('given subnational geography when submitted then creates correct association', async () => { + // Given + const populationState = { + geography: mockStateGeography, + label: 'Test State', + }; + renderComponent(populationState); + + // When + const submitButton = screen.getByRole('button', { name: /Create Geographic Association/i }); + submitButton.click(); + + // Then + await waitFor(() => { + expect(mockCreateGeographicAssociation).toHaveBeenCalledWith( + expect.objectContaining({ + userId: TEST_USER_ID, + geographyType: 'subnational', + geographyIdentifier: `${TEST_COUNTRIES.US}-ca`, + regionCode: 'ca', + regionType: 'state', + label: 'California', + }) + ); + }); + }); + + test('given standalone flow when submitted then resets ingredient', async () => { + // Given + const populationState = { + geography: mockNationalGeography, + }; + const props = { ...mockFlowProps, isInSubflow: false }; + renderComponent(populationState, undefined, props); + + // When + const submitButton = screen.getByRole('button', { name: /Create Geographic Association/i }); + submitButton.click(); + + // Then + await waitFor(() => { + expect(mockResetIngredient).toHaveBeenCalledWith('population'); + }); + }); + + test('given subflow when submitted then does not reset ingredient', async () => { + // Given + const populationState = { + geography: mockNationalGeography, + }; + const props = { ...mockFlowProps, isInSubflow: true }; + renderComponent(populationState, undefined, props); + + // When + const submitButton = screen.getByRole('button', { name: /Create Geographic Association/i }); + submitButton.click(); + + // Then + await waitFor(() => { + expect(mockCreateGeographicAssociation).toHaveBeenCalled(); + }); + expect(mockResetIngredient).not.toHaveBeenCalled(); + }); + + test('given API error when submitted then logs error', async () => { + // Given + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockCreateGeographicAssociation.mockRejectedValue(new Error('API Error')); + const populationState = { + geography: mockNationalGeography, + }; + renderComponent(populationState); + + // When + const submitButton = screen.getByRole('button', { name: /Create Geographic Association/i }); + submitButton.click(); + + // Then + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to create geographic association:', + expect.any(Error) + ); + }); + + consoleSpy.mockRestore(); + }); + }); + + describe('Edge cases', () => { + test('given unknown region code then displays raw code', () => { + // Given + const populationState = { + geography: { + ...mockStateGeography, + geographyId: 'unknown-region', + }, + }; + + // When + renderComponent(populationState); + + // Then + expect(screen.getByText('unknown-region')).toBeInTheDocument(); + }); + + test('given no onReturn prop when submitted then navigates to __return__', async () => { + // Given + const populationState = { + geography: mockNationalGeography, + }; + const mockOnNavigate = vi.fn(); + const props = { + ...mockFlowProps, + onReturn: undefined as any, // Testing edge case where onReturn is not provided + onNavigate: mockOnNavigate, + }; + renderComponent(populationState, undefined, props); + + // When + const submitButton = screen.getByRole('button', { name: /Create Geographic Association/i }); + submitButton.click(); + + // Then + await waitFor(() => { + expect(mockOnNavigate).toHaveBeenCalledWith('__return__'); + }); + }); + + test('given missing metadata country then defaults to us', () => { + // Given + const populationState = { + geography: mockNationalGeography, + }; + + // When + renderComponent(populationState, { currentCountry: null }); + + // Then + expect(screen.getByText('United States')).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/tests/unit/frames/population/HouseholdBuilderFrame.test.tsx b/app/src/tests/unit/frames/population/HouseholdBuilderFrame.test.tsx new file mode 100644 index 00000000..577e77ca --- /dev/null +++ b/app/src/tests/unit/frames/population/HouseholdBuilderFrame.test.tsx @@ -0,0 +1,450 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { screen, waitFor } from '@test-utils'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Provider } from 'react-redux'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { MantineProvider } from '@mantine/core'; +import HouseholdBuilderFrame from '@/frames/population/HouseholdBuilderFrame'; +import metadataReducer from '@/reducers/metadataReducer'; +import populationReducer from '@/reducers/populationReducer'; +import { + getMockHousehold, + mockCreateHouseholdResponse, + mockFlowProps, + mockTaxYears, +} from '@/tests/fixtures/frames/populationMocks'; + +// Mock household utilities +vi.mock('@/utils/HouseholdBuilder', () => ({ + HouseholdBuilder: vi.fn().mockImplementation((_countryId, _taxYear) => ({ + build: vi.fn(() => getMockHousehold()), + loadHousehold: vi.fn(), + addAdult: vi.fn(), + addChild: vi.fn(), + removePerson: vi.fn(), + setMaritalStatus: vi.fn(), + assignToGroupEntity: vi.fn(), + })), +})); + +vi.mock('@/utils/HouseholdQueries', () => ({ + getChildCount: vi.fn(() => 0), + getChildren: vi.fn(() => []), + getPersonVariable: vi.fn((_household, _person, variable, _year) => { + if (variable === 'age') { + return 30; + } + if (variable === 'employment_income') { + return 50000; + } + return 0; + }), +})); + +vi.mock('@/utils/HouseholdValidation', () => ({ + HouseholdValidation: { + isReadyForSimulation: vi.fn(() => ({ isValid: true, errors: [] })), + }, +})); + +// Mock adapter +vi.mock('@/adapters/HouseholdAdapter', () => ({ + HouseholdAdapter: { + toCreationPayload: vi.fn(() => ({ + country_id: 'us', + data: getMockHousehold().householdData, + })), + }, +})); + +// Mock hooks - hoisted to ensure they're available before module load +const { mockCreateHousehold, mockResetIngredient } = vi.hoisted(() => ({ + mockCreateHousehold: vi.fn(), + mockResetIngredient: vi.fn(), +})); + +vi.mock('@/hooks/useCreateHousehold', () => ({ + useCreateHousehold: () => ({ + createHousehold: mockCreateHousehold, + isPending: false, + }), +})); + +vi.mock('@/hooks/useIngredientReset', () => ({ + useIngredientReset: () => ({ + resetIngredient: mockResetIngredient, + }), +})); + +// Mock metadata selectors +const mockBasicInputFields = { + person: ['age', 'employment_income'], + household: ['state_code'], +}; + +const mockFieldOptions = [ + { value: 'CA', label: 'California' }, + { value: 'NY', label: 'New York' }, +]; + +vi.mock('@/libs/metadataUtils', () => ({ + getTaxYears: () => mockTaxYears, + getBasicInputFields: () => mockBasicInputFields, + getFieldLabel: (field: string) => { + const labels: Record = { + state_code: 'State', + age: 'Age', + employment_income: 'Employment Income', + }; + return labels[field] || field; + }, + isDropdownField: (field: string) => field === 'state_code', + getFieldOptions: () => mockFieldOptions, +})); + +describe('HouseholdBuilderFrame', () => { + let store: any; + + beforeEach(() => { + vi.clearAllMocks(); + mockCreateHousehold.mockReset(); + mockResetIngredient.mockReset(); + mockCreateHousehold.mockResolvedValue(mockCreateHouseholdResponse); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + const renderComponent = ( + populationState: any = {}, + metadataState: Partial = { + currentCountry: 'us', + variables: { + age: { defaultValue: 30 }, + employment_income: { defaultValue: 0 }, + }, + basic_inputs: { + person: ['age', 'employment_income'], + household: ['state_code'], + }, + loading: false, + error: null, + }, + props = mockFlowProps + ) => { + const fullMetadataState = { + loading: false, + error: null, + currentCountry: 'us', + variables: { + age: { defaultValue: 30 }, + employment_income: { defaultValue: 0 }, + }, + parameters: {}, + entities: {}, + variableModules: {}, + economyOptions: { region: [], time_period: [], datasets: [] }, + currentLawId: 0, + basicInputs: ['age', 'employment_income'], + basic_inputs: { + person: ['age', 'employment_income'], + household: ['state_code'], + }, + modelledPolicies: { core: {}, filtered: {} }, + version: null, + parameterTree: null, + ...metadataState, + }; + + store = configureStore({ + reducer: { + population: populationReducer, + metadata: metadataReducer, + }, + preloadedState: { + population: populationState, + metadata: fullMetadataState, + }, + }); + + return render( + + + + + + ); + }; + + describe('Component rendering', () => { + test('given component loads then displays household builder form', () => { + // When + renderComponent(); + + // Then + expect(screen.getByText('Build Your Household')).toBeInTheDocument(); + expect(screen.getByText('Tax Year')).toBeInTheDocument(); + expect(screen.getByText('Marital Status')).toBeInTheDocument(); + expect(screen.getByText('Number of Children')).toBeInTheDocument(); + }); + + test('given metadata error then displays error state', () => { + // Given + const metadataState = { + loading: false, + error: 'Failed to load metadata', + }; + + // When + renderComponent({}, metadataState); + + // Then + expect(screen.getByText('Failed to Load Required Data')).toBeInTheDocument(); + expect(screen.getByText(/Unable to load household configuration data/)).toBeInTheDocument(); + }); + + test('given loading state then shows loading overlay', () => { + // Given + const metadataState = { + loading: true, + error: null, + currentCountry: 'us', + variables: {}, + basic_inputs: { person: [], household: [] }, + }; + + // When + renderComponent({}, metadataState); + + // Then + const loadingOverlay = document.querySelector('.mantine-LoadingOverlay-root'); + expect(loadingOverlay).toBeInTheDocument(); + }); + }); + + describe('Household configuration', () => { + test('given marital status changed to married then shows partner fields', async () => { + // Given + const user = userEvent.setup(); + renderComponent(); + + // When + const maritalLabel = screen.getByText('Marital Status'); + const maritalSelect = maritalLabel.parentElement?.querySelector('input') as HTMLElement; + await user.click(maritalSelect); + const marriedOption = await screen.findByText('Married'); + await user.click(marriedOption); + + // Then + await waitFor(() => { + expect(screen.getByText('Your Partner')).toBeInTheDocument(); + }); + }); + + test('given number of children changed then shows child fields', async () => { + // Given + const user = userEvent.setup(); + renderComponent(); + + // When + const childrenLabel = screen.getByText('Number of Children'); + const childrenSelect = childrenLabel.parentElement?.querySelector('input') as HTMLElement; + await user.click(childrenSelect); + const twoChildren = await screen.findByText('2'); + await user.click(twoChildren); + + // Then + await waitFor(() => { + expect(screen.getByText('Child 1')).toBeInTheDocument(); + expect(screen.getByText('Child 2')).toBeInTheDocument(); + }); + }); + + test('given tax year changed then updates household data', async () => { + // Given + const user = userEvent.setup(); + renderComponent(); + + // When + const taxYearLabel = screen.getByText('Tax Year'); + const taxYearSelect = taxYearLabel.parentElement?.querySelector('input') as HTMLElement; + await user.click(taxYearSelect); + const year2023 = await screen.findByText('2023'); + await user.click(year2023); + + // Then + await waitFor(() => { + const taxYearLabel = screen.getByText('Tax Year'); + const taxYearInput = taxYearLabel.parentElement?.querySelector('input') as HTMLInputElement; + expect(taxYearInput.value).toBe('2023'); + }); + }); + }); + + describe('Field value changes', () => { + test('given adult age changed then updates household data', async () => { + // Given + const user = userEvent.setup(); + renderComponent(); + + // When + const ageInputs = screen.getAllByPlaceholderText('Age'); + const primaryAdultAge = ageInputs[0]; + + await user.clear(primaryAdultAge); + await user.type(primaryAdultAge, '35'); + + // Then + await waitFor(() => { + expect(primaryAdultAge).toHaveValue('35'); + }); + }); + + test('given employment income changed then updates household data', async () => { + // Given + const user = userEvent.setup(); + renderComponent(); + + // When + const incomeInputs = screen.getAllByPlaceholderText('Employment Income'); + const primaryIncome = incomeInputs[0]; + + await user.clear(primaryIncome); + await user.type(primaryIncome, '75000'); + + // Then + await waitFor(() => { + const value = (primaryIncome as HTMLInputElement).value; + expect(value).toContain('75'); // Check that the value contains 75 + }); + }); + + test('given household field changed then updates household data', async () => { + // Given + const user = userEvent.setup(); + renderComponent(); + + // When + const stateLabel = screen.getByText('State'); + const stateSelect = stateLabel.parentElement?.querySelector('input') as HTMLElement; + await user.click(stateSelect); + const california = await screen.findByText('California'); + await user.click(california); + + // Then + await waitFor(() => { + const stateLabel2 = screen.getByText('State'); + const stateInput = stateLabel2.parentElement?.querySelector('input') as HTMLInputElement; + expect(stateInput.value).toBe('California'); + }); + }); + }); + + describe('Form submission', () => { + test('given valid household when submitted then creates household', async () => { + // Given + const user = userEvent.setup(); + const mockHouseholdData = getMockHousehold(); + const populationState = { + label: 'Test Household', + household: mockHouseholdData, + }; + const props = { ...mockFlowProps }; + renderComponent(populationState, undefined, props); + + // When + const submitButton = screen.getByRole('button', { name: /Create household/i }); + await user.click(submitButton); + + // Then + await waitFor(() => { + expect(mockCreateHousehold).toHaveBeenCalledWith( + expect.objectContaining({ + country_id: 'us', + data: mockHouseholdData.householdData, + }) + ); + }); + + await waitFor(() => { + expect(props.onReturn).toHaveBeenCalled(); + }); + }); + + test('given invalid household when submitted then does not create', async () => { + // Given + const { HouseholdValidation } = await import('@/utils/HouseholdValidation'); + (HouseholdValidation.isReadyForSimulation as any).mockReturnValue({ + isValid: false, + errors: ['Missing required fields'], + }); + + renderComponent(); + + // When + const submitButton = screen.getByRole('button', { name: /Create household/i }); + + // Then + expect(submitButton).toBeDisabled(); + }); + }); + + describe('Complex household scenarios', () => { + test('given married with children configuration then creates complete household', async () => { + // Given + const user = userEvent.setup(); + renderComponent(); + + // When - Configure married with 2 children + const maritalLabel2 = screen.getByText('Marital Status'); + const maritalSelect2 = maritalLabel2.parentElement?.querySelector('input') as HTMLElement; + await user.click(maritalSelect2); + const marriedOption = await screen.findByText('Married'); + await user.click(marriedOption); + + const childrenLabel2 = screen.getByText('Number of Children'); + const childrenSelect2 = childrenLabel2.parentElement?.querySelector('input') as HTMLElement; + await user.click(childrenSelect2); + const twoChildren = await screen.findByText('2'); + await user.click(twoChildren); + + // Then - Verify all family members are displayed + await waitFor(() => { + expect(screen.getByText('You')).toBeInTheDocument(); + expect(screen.getByText('Your Partner')).toBeInTheDocument(); + expect(screen.getByText('Child 1')).toBeInTheDocument(); + expect(screen.getByText('Child 2')).toBeInTheDocument(); + }); + }); + + test('given switching from married to single then removes partner', async () => { + // Given + const user = userEvent.setup(); + renderComponent(); + + // When - Set to married first + const maritalLabel = screen.getByText('Marital Status'); + const maritalSelect = maritalLabel.parentElement?.querySelector('input') as HTMLElement; + await user.click(maritalSelect); + const marriedOption = await screen.findByText('Married'); + await user.click(marriedOption); + + // Verify partner appears + await waitFor(() => { + expect(screen.getByText('Your Partner')).toBeInTheDocument(); + }); + + // Then switch back to single + await user.click(maritalSelect); + const singleOption = await screen.findByText('Single'); + await user.click(singleOption); + + // Then - Partner should be removed + await waitFor(() => { + expect(screen.queryByText('Your Partner')).not.toBeInTheDocument(); + }); + }); + }); +}); diff --git a/app/src/tests/unit/frames/population/SelectGeographicScopeFrame.test.tsx b/app/src/tests/unit/frames/population/SelectGeographicScopeFrame.test.tsx new file mode 100644 index 00000000..0cbd8b8f --- /dev/null +++ b/app/src/tests/unit/frames/population/SelectGeographicScopeFrame.test.tsx @@ -0,0 +1,384 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { screen, waitFor } from '@test-utils'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Provider } from 'react-redux'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { MantineProvider } from '@mantine/core'; +import SelectGeographicScopeFrame from '@/frames/population/SelectGeographicScopeFrame'; +import metadataReducer from '@/reducers/metadataReducer'; +import populationReducer from '@/reducers/populationReducer'; +import { + GEOGRAPHIC_SCOPES, + mockFlowProps, + TEST_COUNTRIES, +} from '@/tests/fixtures/frames/populationMocks'; + +// Mock the regions data +vi.mock('@/mocks/regions', () => ({ + us_regions: { + result: { + economy_options: { + region: [ + { name: 'us', label: 'United States' }, + { name: 'state/ca', label: 'California' }, + { name: 'state/ny', label: 'New York' }, + { name: 'state/tx', label: 'Texas' }, + ], + }, + }, + }, + uk_regions: { + result: { + economy_options: { + region: [ + { name: 'uk', label: 'United Kingdom' }, + { name: 'country/england', label: 'England' }, + { name: 'country/scotland', label: 'Scotland' }, + { name: 'constituency/london', label: 'London' }, + { name: 'constituency/manchester', label: 'Manchester' }, + ], + }, + }, + }, +})); + +describe('SelectGeographicScopeFrame', () => { + let store: any; + const user = userEvent.setup(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const renderComponent = ( + metadataState: Partial = { currentCountry: TEST_COUNTRIES.US as string }, + props = mockFlowProps + ) => { + const fullMetadataState = { + loading: false, + error: null, + currentCountry: TEST_COUNTRIES.US as string, + variables: {}, + parameters: {}, + entities: {}, + variableModules: {}, + economyOptions: { region: [], time_period: [], datasets: [] }, + currentLawId: 0, + basicInputs: [], + modelledPolicies: { core: {}, filtered: {} }, + version: null, + parameterTree: null, + ...metadataState, + }; + + store = configureStore({ + reducer: { + population: populationReducer, + metadata: metadataReducer, + }, + preloadedState: { + population: {}, + metadata: fullMetadataState, + }, + }); + + return render( + + + + + + ); + }; + + describe('Component rendering', () => { + test('given component loads then displays all scope options', () => { + // When + renderComponent(); + + // Then + expect(screen.getByRole('heading', { name: 'Select Scope' })).toBeInTheDocument(); + expect(screen.getByLabelText('National')).toBeInTheDocument(); + expect(screen.getByLabelText('State')).toBeInTheDocument(); + expect(screen.getByLabelText('Household')).toBeInTheDocument(); + }); + + test('given initial state then national is selected by default', () => { + // When + renderComponent(); + + // Then + const nationalRadio = screen.getByLabelText('National') as HTMLInputElement; + expect(nationalRadio.checked).toBe(true); + }); + }); + + describe('Scope selection', () => { + test('given state scope selected then shows state dropdown for US', async () => { + // Given + renderComponent(); + + // When + const stateRadio = screen.getByLabelText('State'); + await user.click(stateRadio); + + // Then + await waitFor(() => { + expect(screen.getByPlaceholderText('Pick a state')).toBeInTheDocument(); + }); + + // And the dropdown should have US states + const dropdown = screen.getByPlaceholderText('Pick a state'); + await user.click(dropdown); + + await waitFor(() => { + expect(screen.getByText('California')).toBeInTheDocument(); + expect(screen.getByText('New York')).toBeInTheDocument(); + expect(screen.getByText('Texas')).toBeInTheDocument(); + }); + }); + + test('given UK country and state scope then shows constituency options', async () => { + // Given + renderComponent({ currentCountry: TEST_COUNTRIES.UK }); + + // When + const stateRadio = screen.getByLabelText('State'); + await user.click(stateRadio); + + // Then - Shows UK country selector + await waitFor(() => { + expect(screen.getByPlaceholderText('Pick a country')).toBeInTheDocument(); + }); + + // Select a UK country + const countryDropdown = screen.getByPlaceholderText('Pick a country'); + await user.click(countryDropdown); + await waitFor(() => { + expect(screen.getByText('England')).toBeInTheDocument(); + expect(screen.getByText('Scotland')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('England')); + + // Then shows constituency selector + await waitFor(() => { + expect(screen.getByPlaceholderText('Pick a constituency')).toBeInTheDocument(); + }); + + const constituencyDropdown = screen.getByPlaceholderText('Pick a constituency'); + await user.click(constituencyDropdown); + + await waitFor(() => { + expect(screen.getByText('London')).toBeInTheDocument(); + expect(screen.getByText('Manchester')).toBeInTheDocument(); + }); + }); + + test('given household scope selected then hides region selectors', async () => { + // Given + renderComponent(); + + // First select state to show dropdown + const stateRadio = screen.getByLabelText('State'); + await user.click(stateRadio); + + await waitFor(() => { + expect(screen.getByPlaceholderText('Pick a state')).toBeInTheDocument(); + }); + + // When - Switch to household + const householdRadio = screen.getByLabelText('Household'); + await user.click(householdRadio); + + // Then - Dropdown should be hidden + await waitFor(() => { + expect(screen.queryByPlaceholderText('Pick a state')).not.toBeInTheDocument(); + }); + }); + }); + + describe('Form submission', () => { + test('given national scope when submitted then creates national geography', async () => { + // Given + const props = { ...mockFlowProps }; + renderComponent(undefined, props); + + // When + const submitButton = screen.getByRole('button', { name: /Select Scope/i }); + await user.click(submitButton); + + // Then + expect(props.onNavigate).toHaveBeenCalledWith(GEOGRAPHIC_SCOPES.NATIONAL); + + // Verify Redux action was dispatched + const state = store.getState(); + expect(state.population.geography).toEqual( + expect.objectContaining({ + id: TEST_COUNTRIES.US, + countryId: TEST_COUNTRIES.US, + scope: 'national', + geographyId: TEST_COUNTRIES.US, + }) + ); + }); + + test('given state scope with selected region when submitted then creates subnational geography', async () => { + // Given + const props = { ...mockFlowProps }; + renderComponent(undefined, props); + + // When + const stateRadio = screen.getByLabelText('State'); + await user.click(stateRadio); + + const dropdown = await screen.findByPlaceholderText('Pick a state'); + await user.click(dropdown); + + const california = await screen.findByText('California'); + await user.click(california); + + const submitButton = screen.getByRole('button', { name: /Select Scope/i }); + await user.click(submitButton); + + // Then + expect(props.onNavigate).toHaveBeenCalledWith(GEOGRAPHIC_SCOPES.STATE); + + const state = store.getState(); + expect(state.population.geography).toEqual( + expect.objectContaining({ + id: `${TEST_COUNTRIES.US}-ca`, + countryId: TEST_COUNTRIES.US, + scope: 'subnational', + geographyId: 'ca', + }) + ); + }); + + test('given state scope without region selected when submitted then does not navigate', async () => { + // Given + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const props = { ...mockFlowProps }; + renderComponent(undefined, props); + + // When + const stateRadio = screen.getByLabelText('State'); + await user.click(stateRadio); + + const submitButton = screen.getByRole('button', { name: /Select Scope/i }); + await user.click(submitButton); + + // Then + expect(props.onNavigate).not.toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith('State selected but no region chosen'); + + consoleSpy.mockRestore(); + }); + + test('given household scope when submitted then navigates without creating geography', async () => { + // Given + const props = { ...mockFlowProps }; + renderComponent(undefined, props); + + // When + const householdRadio = screen.getByLabelText('Household'); + await user.click(householdRadio); + + const submitButton = screen.getByRole('button', { name: /Select Scope/i }); + await user.click(submitButton); + + // Then + expect(props.onNavigate).toHaveBeenCalledWith(GEOGRAPHIC_SCOPES.HOUSEHOLD); + + // Geography should not be set for household scope + const state = store.getState(); + expect(state.population.geography).toBeUndefined(); + }); + }); + + describe('Region value extraction', () => { + test('given region with slash prefix when submitted then extracts correct value', async () => { + // Given + const props = { ...mockFlowProps }; + renderComponent(undefined, props); + + // When + const stateRadio = screen.getByLabelText('State'); + await user.click(stateRadio); + + const dropdown = await screen.findByPlaceholderText('Pick a state'); + await user.click(dropdown); + + // Select state/ca which should extract to 'ca' + const california = await screen.findByText('California'); + await user.click(california); + + const submitButton = screen.getByRole('button', { name: /Select Scope/i }); + await user.click(submitButton); + + // Then + const state = store.getState(); + expect(state.population.geography.geographyId).toBe('ca'); // Not 'state/ca' + }); + + test('given UK constituency when submitted then extracts constituency name', async () => { + // Given + const props = { ...mockFlowProps }; + renderComponent({ currentCountry: TEST_COUNTRIES.UK }, props); + + // When + const stateRadio = screen.getByLabelText('State'); + await user.click(stateRadio); + + // Select UK country + const countryDropdown = await screen.findByPlaceholderText('Pick a country'); + await user.click(countryDropdown); + const england = await screen.findByText('England'); + await user.click(england); + + // Select constituency + const constituencyDropdown = await screen.findByPlaceholderText('Pick a constituency'); + await user.click(constituencyDropdown); + const london = await screen.findByText('London'); + await user.click(london); + + const submitButton = screen.getByRole('button', { name: /Select Scope/i }); + await user.click(submitButton); + + // Then + const state = store.getState(); + expect(state.population.geography.geographyId).toBe('london'); // Not 'constituency/london' + }); + }); + + describe('Country-specific behavior', () => { + test('given no metadata country then defaults to US', () => { + // Given + renderComponent({ currentCountry: null }); + + // When + const stateRadio = screen.getByLabelText('State'); + stateRadio.click(); + + // Then - Should show US states + waitFor(() => { + expect(screen.getByPlaceholderText('Pick a state')).toBeInTheDocument(); + }); + }); + + test('given unknown country then defaults to US behavior', () => { + // Given + renderComponent({ currentCountry: 'ca' }); // Canada not implemented + + // When + const stateRadio = screen.getByLabelText('State'); + stateRadio.click(); + + // Then - Should show US states as fallback + waitFor(() => { + expect(screen.getByPlaceholderText('Pick a state')).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/app/src/tests/unit/frames/population/SetPopulationLabelFrame.test.tsx b/app/src/tests/unit/frames/population/SetPopulationLabelFrame.test.tsx new file mode 100644 index 00000000..6b493e39 --- /dev/null +++ b/app/src/tests/unit/frames/population/SetPopulationLabelFrame.test.tsx @@ -0,0 +1,324 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { screen } from '@test-utils'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Provider } from 'react-redux'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { MantineProvider } from '@mantine/core'; +import SetPopulationLabelFrame from '@/frames/population/SetPopulationLabelFrame'; +import populationReducer from '@/reducers/populationReducer'; +import { + getMockHousehold, + LONG_LABEL, + mockFlowProps, + mockNationalGeography, + mockStateGeography, + TEST_POPULATION_LABEL, + TEST_VALUES, + UI_TEXT, +} from '@/tests/fixtures/frames/populationMocks'; + +describe('SetPopulationLabelFrame', () => { + let store: any; + const user = userEvent.setup(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const renderComponent = (populationState = {}, props = mockFlowProps) => { + store = configureStore({ + reducer: { + population: populationReducer, + metadata: () => ({}), + }, + preloadedState: { + population: populationState, + metadata: {}, + }, + }); + + return render( + + + + + + ); + }; + + describe('Component rendering', () => { + test('given component loads then displays label input form', () => { + // When + renderComponent(); + + // Then + expect(screen.getByText(UI_TEXT.NAME_POPULATION_TITLE)).toBeInTheDocument(); + expect(screen.getByText(UI_TEXT.POPULATION_LABEL)).toBeInTheDocument(); + expect(screen.getByText(UI_TEXT.LABEL_DESCRIPTION)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER)).toBeInTheDocument(); + }); + + test('given existing label then pre-fills input', () => { + // Given + const populationState = { + label: TEST_POPULATION_LABEL, + }; + + // When + renderComponent(populationState); + + // Then + const input = screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER) as HTMLInputElement; + expect(input.value).toBe(TEST_POPULATION_LABEL); + }); + }); + + describe('Default labels', () => { + test('given national geography then suggests National Population', () => { + // Given + const populationState = { + geography: mockNationalGeography, + }; + + // When + renderComponent(populationState); + + // Then + const input = screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER) as HTMLInputElement; + expect(input.value).toBe(UI_TEXT.DEFAULT_NATIONAL_LABEL); + }); + + test('given state geography then suggests state name population', () => { + // Given + const populationState = { + geography: mockStateGeography, + }; + + // When + renderComponent(populationState); + + // Then + const input = screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER) as HTMLInputElement; + expect(input.value).toBe(UI_TEXT.DEFAULT_STATE_LABEL('ca')); + }); + + test('given household then suggests Custom Household', () => { + // Given + const populationState = { + household: getMockHousehold(), + }; + + // When + renderComponent(populationState); + + // Then + const input = screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER) as HTMLInputElement; + expect(input.value).toBe(UI_TEXT.DEFAULT_HOUSEHOLD_LABEL); + }); + + test('given no population type then defaults to Custom Household', () => { + // When + renderComponent(); + + // Then + const input = screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER) as HTMLInputElement; + expect(input.value).toBe(UI_TEXT.DEFAULT_HOUSEHOLD_LABEL); + }); + }); + + describe('Label validation', () => { + test('given empty label when submitted then shows error', async () => { + // Given + renderComponent(); + const input = screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER); + + // When + await user.clear(input); + const submitButton = screen.getByRole('button', { name: UI_TEXT.CONTINUE_BUTTON }); + await user.click(submitButton); + + // Then + expect(screen.getByText(UI_TEXT.ERROR_EMPTY_LABEL)).toBeInTheDocument(); + }); + + test('given whitespace-only label when submitted then shows error', async () => { + // Given + renderComponent(); + const input = screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER); + + // When + await user.clear(input); + await user.type(input, ' '); + const submitButton = screen.getByRole('button', { name: UI_TEXT.CONTINUE_BUTTON }); + await user.click(submitButton); + + // Then + expect(screen.getByText(UI_TEXT.ERROR_EMPTY_LABEL)).toBeInTheDocument(); + }); + + test('given label over 100 characters when submitted then shows error', async () => { + // Given - Input field should have maxLength constraint + renderComponent(); + const input = screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER); + + // Then - Verify the input has maxLength attribute set to 100 + expect(input).toHaveAttribute('maxlength', '100'); + + // When - Try to type more than 100 characters + await user.clear(input); + await user.type(input, LONG_LABEL); // Will be truncated to 100 chars + + // Then - Verify only 100 characters were accepted + expect(input).toHaveValue('A'.repeat(100)); + }); + + test('given error shown when user types then clears error', async () => { + // Given + renderComponent(); + const input = screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER); + + // Create error first + await user.clear(input); + const submitButton = screen.getByRole('button', { name: UI_TEXT.CONTINUE_BUTTON }); + await user.click(submitButton); + + expect(screen.getByText(UI_TEXT.ERROR_EMPTY_LABEL)).toBeInTheDocument(); + + // When - User starts typing + await user.type(input, 'New Label'); + + // Then - Error should be cleared + expect(screen.queryByText(UI_TEXT.ERROR_EMPTY_LABEL)).not.toBeInTheDocument(); + }); + }); + + describe('Form submission', () => { + test('given valid label with geography when submitted then navigates to geographic', async () => { + // Given + const populationState = { + geography: mockNationalGeography, + }; + const props = { ...mockFlowProps }; + renderComponent(populationState, props); + + const input = screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER); + await user.clear(input); + await user.type(input, 'My National Population'); + + // When + const submitButton = screen.getByRole('button', { name: UI_TEXT.CONTINUE_BUTTON }); + await user.click(submitButton); + + // Then + expect(props.onNavigate).toHaveBeenCalledWith('geographic'); + + // Verify Redux action was dispatched + const state = store.getState(); + expect(state.population.label).toBe('My National Population'); + }); + + test('given valid label with household when submitted then navigates to household', async () => { + // Given + const populationState = { + household: getMockHousehold(), + }; + const props = { ...mockFlowProps }; + renderComponent(populationState, props); + + const input = screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER); + await user.clear(input); + await user.type(input, 'My Family 2024'); + + // When + const submitButton = screen.getByRole('button', { name: UI_TEXT.CONTINUE_BUTTON }); + await user.click(submitButton); + + // Then + expect(props.onNavigate).toHaveBeenCalledWith('household'); + + const state = store.getState(); + expect(state.population.label).toBe('My Family 2024'); + }); + + test('given label with leading/trailing spaces when submitted then trims label', async () => { + // Given + const props = { ...mockFlowProps }; + renderComponent({}, props); + + const input = screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER); + await user.clear(input); + await user.type(input, ' Trimmed Label '); + + // When + const submitButton = screen.getByRole('button', { name: UI_TEXT.CONTINUE_BUTTON }); + await user.click(submitButton); + + // Then + const state = store.getState(); + expect(state.population.label).toBe('Trimmed Label'); + }); + + test('given no population type when submitted then defaults to household navigation', async () => { + // Given - No geography or household set + const props = { ...mockFlowProps }; + renderComponent({}, props); + + const input = screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER); + await user.clear(input); + await user.type(input, 'Default Population'); + + // When + const submitButton = screen.getByRole('button', { name: UI_TEXT.CONTINUE_BUTTON }); + await user.click(submitButton); + + // Then - Should navigate to household by default + expect(props.onNavigate).toHaveBeenCalledWith('household'); + }); + }); + + describe('Navigation', () => { + test('given back button clicked then navigates back', async () => { + // Given + const props = { ...mockFlowProps }; + renderComponent({}, props); + + // When + const backButton = screen.getByRole('button', { name: UI_TEXT.BACK_BUTTON }); + await user.click(backButton); + + // Then + expect(props.onNavigate).toHaveBeenCalledWith('back'); + }); + }); + + describe('Input constraints', () => { + test('given input has maxLength attribute then limits to 100 characters', () => { + // When + renderComponent(); + + // Then + const input = screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER) as HTMLInputElement; + expect(input.maxLength).toBe(TEST_VALUES.LABEL_MAX_LENGTH); + }); + + test('given input is required then has required attribute', () => { + // When + renderComponent(); + + // Then + const input = screen.getByPlaceholderText(UI_TEXT.LABEL_PLACEHOLDER) as HTMLInputElement; + expect(input.required).toBe(true); + }); + }); + + describe('Help text', () => { + test('given component rendered then shows help text', () => { + // When + renderComponent(); + + // Then + expect(screen.getByText(UI_TEXT.LABEL_HELP_TEXT)).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/tests/unit/hooks/useCreateHousehold.test.tsx b/app/src/tests/unit/hooks/useCreateHousehold.test.tsx new file mode 100644 index 00000000..d3fd6a53 --- /dev/null +++ b/app/src/tests/unit/hooks/useCreateHousehold.test.tsx @@ -0,0 +1,297 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { createHousehold } from '@/api/household'; +// Now import the actual implementations +import { useCreateHousehold } from '@/hooks/useCreateHousehold'; +import { useCreateHouseholdAssociation } from '@/hooks/useUserHousehold'; +// Import fixtures first +import { + CONSOLE_MESSAGES, + createMockQueryClient, + ERROR_MESSAGES, + mockCreateHousehold, + mockCreateHouseholdAssociationMutateAsync, + mockCreateHouseholdResponse, + mockHouseholdCreationPayload, + QUERY_KEY_PATTERNS, + setupMockConsole, + TEST_IDS, + TEST_LABELS, +} from '@/tests/fixtures/hooks/hooksMocks'; + +// Mock the modules before importing them +vi.mock('@/api/household', () => ({ + createHousehold: vi.fn(), +})); + +vi.mock('@/constants', () => ({ + MOCK_USER_ID: 'test-user-123', +})); + +vi.mock('@/libs/queryKeys', () => ({ + householdKeys: { + all: ['households'], + byId: (id: string) => ['households', 'byId', id], + }, +})); + +vi.mock('@/hooks/useUserHousehold', () => ({ + useCreateHouseholdAssociation: vi.fn(), +})); + +describe('useCreateHousehold', () => { + let queryClient: QueryClient; + let consoleMocks: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + queryClient = createMockQueryClient(); + consoleMocks = setupMockConsole(); + + // Set up the mocked functions + (createHousehold as any).mockImplementation(mockCreateHousehold); + (useCreateHouseholdAssociation as any).mockReturnValue({ + mutateAsync: mockCreateHouseholdAssociationMutateAsync, + }); + + // Set default mock implementations + mockCreateHousehold.mockResolvedValue(mockCreateHouseholdResponse); + mockCreateHouseholdAssociationMutateAsync.mockResolvedValue({ + userId: TEST_IDS.USER_ID, + householdId: TEST_IDS.HOUSEHOLD_ID, + label: TEST_LABELS.HOUSEHOLD, + }); + }); + + afterEach(() => { + consoleMocks.restore(); + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + describe('successful household creation', () => { + test('given valid payload when createHousehold called then creates household and association', async () => { + // Given + const { result } = renderHook(() => useCreateHousehold(TEST_LABELS.HOUSEHOLD), { wrapper }); + + // When + const promise = result.current.createHousehold(mockHouseholdCreationPayload); + + // Wait for completion + const response = await promise; + + await waitFor(() => { + expect(result.current.isPending).toBe(false); + }); + + // Verify API calls + expect(mockCreateHousehold).toHaveBeenCalledWith(mockHouseholdCreationPayload); + expect(mockCreateHouseholdAssociationMutateAsync).toHaveBeenCalledWith({ + userId: TEST_IDS.USER_ID, + householdId: TEST_IDS.HOUSEHOLD_ID, + label: TEST_LABELS.HOUSEHOLD, + }); + + // Verify response + expect(response).toEqual(mockCreateHouseholdResponse); + + // Verify query invalidation + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: QUERY_KEY_PATTERNS.HOUSEHOLD_ALL, + }); + }); + + test('given no label when createHousehold called then passes undefined label to association', async () => { + // Given + const { result } = renderHook(() => useCreateHousehold(), { wrapper }); + + // When + await result.current.createHousehold(mockHouseholdCreationPayload); + + // Then + expect(mockCreateHouseholdAssociationMutateAsync).toHaveBeenCalledWith({ + userId: TEST_IDS.USER_ID, + householdId: TEST_IDS.HOUSEHOLD_ID, + label: undefined, + }); + }); + + test('given custom label when createHousehold called then uses provided label', async () => { + // Given + const customLabel = 'Custom Household Label'; + const { result } = renderHook(() => useCreateHousehold(customLabel), { wrapper }); + + // When + await result.current.createHousehold(mockHouseholdCreationPayload); + + // Then + expect(mockCreateHouseholdAssociationMutateAsync).toHaveBeenCalledWith({ + userId: TEST_IDS.USER_ID, + householdId: TEST_IDS.HOUSEHOLD_ID, + label: customLabel, + }); + }); + }); + + describe('error handling', () => { + test('given household creation fails when createHousehold called then propagates error', async () => { + // Given + const error = new Error(ERROR_MESSAGES.CREATE_FAILED); + mockCreateHousehold.mockRejectedValue(error); + const { result } = renderHook(() => useCreateHousehold(TEST_LABELS.HOUSEHOLD), { wrapper }); + + // When/Then + await expect(result.current.createHousehold(mockHouseholdCreationPayload)).rejects.toThrow( + ERROR_MESSAGES.CREATE_FAILED + ); + + await waitFor(() => { + expect(result.current.error).toEqual(error); + }); + + // Association should not be created + expect(mockCreateHouseholdAssociationMutateAsync).not.toHaveBeenCalled(); + }); + + test('given association creation fails when household created then logs error but completes', async () => { + // Given + const associationError = new Error(ERROR_MESSAGES.ASSOCIATION_FAILED); + mockCreateHouseholdAssociationMutateAsync.mockRejectedValue(associationError); + const { result } = renderHook(() => useCreateHousehold(TEST_LABELS.HOUSEHOLD), { wrapper }); + + // When + const response = await result.current.createHousehold(mockHouseholdCreationPayload); + + // Then + expect(response).toEqual(mockCreateHouseholdResponse); + expect(consoleMocks.consoleSpy.error).toHaveBeenCalledWith( + CONSOLE_MESSAGES.ASSOCIATION_ERROR, + associationError + ); + + // Household creation should succeed + expect(mockCreateHousehold).toHaveBeenCalledWith(mockHouseholdCreationPayload); + }); + }); + + describe('state management', () => { + test('given hook initialized then isPending is false', () => { + // When + const { result } = renderHook(() => useCreateHousehold(TEST_LABELS.HOUSEHOLD), { wrapper }); + + // Then + expect(result.current.isPending).toBe(false); + expect(result.current.error).toBeNull(); + }); + + test('given mutation in progress then isPending is true', async () => { + // Given + let resolveMutation: (value: any) => void; + const pendingPromise = new Promise((resolve) => { + resolveMutation = resolve; + }); + mockCreateHousehold.mockReturnValue(pendingPromise); + + const { result } = renderHook(() => useCreateHousehold(TEST_LABELS.HOUSEHOLD), { wrapper }); + + // When + result.current.createHousehold(mockHouseholdCreationPayload); + + // Then + await waitFor(() => { + expect(result.current.isPending).toBe(true); + }); + + // Cleanup + resolveMutation!(mockCreateHouseholdResponse); + await pendingPromise; + }); + + test('given mutation completes then isPending returns to false', async () => { + // Given + const { result } = renderHook(() => useCreateHousehold(TEST_LABELS.HOUSEHOLD), { wrapper }); + + // When + await result.current.createHousehold(mockHouseholdCreationPayload); + + // Then + await waitFor(() => { + expect(result.current.isPending).toBe(false); + }); + }); + }); + + describe('query invalidation', () => { + test('given successful creation then invalidates household queries', async () => { + // Given + const { result } = renderHook(() => useCreateHousehold(TEST_LABELS.HOUSEHOLD), { wrapper }); + + // When + await result.current.createHousehold(mockHouseholdCreationPayload); + + // Then + await waitFor(() => { + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: QUERY_KEY_PATTERNS.HOUSEHOLD_ALL, + }); + }); + }); + + test('given failed creation then does not invalidate queries', async () => { + // Given + mockCreateHousehold.mockRejectedValue(new Error(ERROR_MESSAGES.CREATE_FAILED)); + const { result } = renderHook(() => useCreateHousehold(TEST_LABELS.HOUSEHOLD), { wrapper }); + + // When + try { + await result.current.createHousehold(mockHouseholdCreationPayload); + } catch { + // Expected error + } + + // Then + expect(queryClient.invalidateQueries).not.toHaveBeenCalled(); + }); + }); + + describe('edge cases', () => { + test('given multiple concurrent calls then handles correctly', async () => { + // Given + const { result } = renderHook(() => useCreateHousehold(TEST_LABELS.HOUSEHOLD), { wrapper }); + + // When - Create multiple households concurrently + const promises = [ + result.current.createHousehold(mockHouseholdCreationPayload), + result.current.createHousehold(mockHouseholdCreationPayload), + ]; + + // Then + const results = await Promise.all(promises); + + expect(results).toHaveLength(2); + expect(mockCreateHousehold).toHaveBeenCalledTimes(2); + expect(mockCreateHouseholdAssociationMutateAsync).toHaveBeenCalledTimes(2); + }); + + test('given household ID missing in response then still attempts association', async () => { + // Given + const responseWithoutId = { result: {} }; + mockCreateHousehold.mockResolvedValue(responseWithoutId); + const { result } = renderHook(() => useCreateHousehold(TEST_LABELS.HOUSEHOLD), { wrapper }); + + // When + await result.current.createHousehold(mockHouseholdCreationPayload); + + // Then + expect(mockCreateHouseholdAssociationMutateAsync).toHaveBeenCalledWith({ + userId: TEST_IDS.USER_ID, + householdId: undefined, + label: TEST_LABELS.HOUSEHOLD, + }); + }); + }); +}); diff --git a/app/src/tests/unit/hooks/useUserGeographic.test.tsx b/app/src/tests/unit/hooks/useUserGeographic.test.tsx new file mode 100644 index 00000000..cbde38f1 --- /dev/null +++ b/app/src/tests/unit/hooks/useUserGeographic.test.tsx @@ -0,0 +1,404 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { SessionStorageGeographicStore } from '@/api/geographicAssociation'; +// Now import everything +import { + useCreateGeographicAssociation, + useGeographicAssociation, + useGeographicAssociationsByUser, + useUserGeographicStore, +} from '@/hooks/useUserGeographic'; +import { + createMockQueryClient, + GEO_CONSTANTS, + mockUserGeographicAssociation, + mockUserGeographicAssociationList, + QUERY_KEY_PATTERNS, + TEST_IDS, + TEST_LABELS, +} from '@/tests/fixtures/hooks/hooksMocks'; + +// Mock the stores first +vi.mock('@/api/geographicAssociation', () => { + const mockStore = { + create: vi.fn(), + findByUser: vi.fn(), + findById: vi.fn(), + }; + return { + ApiGeographicStore: vi.fn(() => mockStore), + SessionStorageGeographicStore: vi.fn(() => mockStore), + }; +}); + +// Mock query config +vi.mock('@/libs/queryConfig', () => ({ + queryConfig: { + api: { + staleTime: 5 * 60 * 1000, + cacheTime: 10 * 60 * 1000, + }, + sessionStorage: { + staleTime: 0, + cacheTime: 0, + }, + }, +})); + +// Mock query keys +vi.mock('@/libs/queryKeys', () => ({ + geographicAssociationKeys: { + byUser: (userId: string) => ['geographic-associations', 'byUser', userId], + byGeography: (id: string) => ['geographic-associations', 'byGeography', id], + specific: (userId: string, id: string) => ['geographic-associations', 'specific', userId, id], + }, +})); + +describe('useUserGeographic hooks', () => { + let queryClient: QueryClient; + + beforeEach(() => { + vi.clearAllMocks(); + queryClient = createMockQueryClient(); + + // Get the mock store instance + const mockStore = + (SessionStorageGeographicStore as any).mock.results[0]?.value || + (SessionStorageGeographicStore as any)(); + + // Set default mock implementations + mockStore.create.mockResolvedValue(mockUserGeographicAssociation); + mockStore.findByUser.mockResolvedValue(mockUserGeographicAssociationList); + mockStore.findById.mockResolvedValue(mockUserGeographicAssociation); + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + describe('useUserGeographicStore', () => { + test('given user not logged in then returns session storage store', () => { + // When + const { result } = renderHook(() => useUserGeographicStore()); + + // Then + expect(result.current).toBeDefined(); + expect(result.current.create).toBeDefined(); + expect(result.current.findByUser).toBeDefined(); + expect(result.current.findById).toBeDefined(); + }); + + // Note: Cannot test logged-in case as isLoggedIn is hardcoded to false + // This would need to be refactored to accept auth context + }); + + describe('useGeographicAssociationsByUser', () => { + test('given valid user ID when fetching associations then returns list', async () => { + // Given + const userId = TEST_IDS.USER_ID; + + // When + const { result } = renderHook(() => useGeographicAssociationsByUser(userId), { wrapper }); + + // Then + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual(mockUserGeographicAssociationList); + const mockStore = (SessionStorageGeographicStore as any)(); + expect(mockStore.findByUser).toHaveBeenCalledWith(userId); + }); + + test('given store throws error when fetching then returns error state', async () => { + // Given + const error = new Error('Failed to fetch associations'); + const mockStore = (SessionStorageGeographicStore as any)(); + mockStore.findByUser.mockRejectedValue(error); + + // When + const { result } = renderHook(() => useGeographicAssociationsByUser(TEST_IDS.USER_ID), { + wrapper, + }); + + // Then + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(error); + }); + + test('given empty user ID when fetching then still attempts fetch', async () => { + // When + const { result } = renderHook(() => useGeographicAssociationsByUser(''), { wrapper }); + + // Then + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + const mockStore = (SessionStorageGeographicStore as any)(); + expect(mockStore.findByUser).toHaveBeenCalledWith(''); + }); + + test('given user with no associations then returns empty array', async () => { + // Given + const mockStore = (SessionStorageGeographicStore as any)(); + mockStore.findByUser.mockResolvedValue([]); + + // When + const { result } = renderHook(() => useGeographicAssociationsByUser(TEST_IDS.USER_ID), { + wrapper, + }); + + // Then + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual([]); + }); + }); + + describe('useGeographicAssociation', () => { + test('given valid IDs when fetching specific association then returns data', async () => { + // Given + const userId = TEST_IDS.USER_ID; + const geographyId = TEST_IDS.GEOGRAPHY_ID; + + // When + const { result } = renderHook(() => useGeographicAssociation(userId, geographyId), { + wrapper, + }); + + // Then + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual(mockUserGeographicAssociation); + const mockStore = (SessionStorageGeographicStore as any)(); + expect(mockStore.findById).toHaveBeenCalledWith(userId, geographyId); + }); + + test('given non-existent association when fetching then returns null', async () => { + // Given + const mockStore = (SessionStorageGeographicStore as any)(); + mockStore.findById.mockResolvedValue(null); + + // When + const { result } = renderHook( + () => useGeographicAssociation(TEST_IDS.USER_ID, 'non-existent'), + { wrapper } + ); + + // Then + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toBeNull(); + }); + + test('given error in fetching then returns error state', async () => { + // Given + const error = new Error('Network error'); + const mockStore = (SessionStorageGeographicStore as any)(); + mockStore.findById.mockRejectedValue(error); + + // When + const { result } = renderHook( + () => useGeographicAssociation(TEST_IDS.USER_ID, TEST_IDS.GEOGRAPHY_ID), + { wrapper } + ); + + // Then + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(error); + }); + }); + + describe('useCreateGeographicAssociation', () => { + test('given valid association when created then updates cache correctly', async () => { + // Given + const newAssociation = { + id: TEST_IDS.GEOGRAPHY_ID, + userId: TEST_IDS.USER_ID, + countryCode: GEO_CONSTANTS.COUNTRY_US, + geographyType: GEO_CONSTANTS.TYPE_NATIONAL, + geographyIdentifier: GEO_CONSTANTS.COUNTRY_US, + label: TEST_LABELS.GEOGRAPHY, + } as const; + + const { result } = renderHook(() => useCreateGeographicAssociation(), { wrapper }); + + // When + await result.current.mutateAsync(newAssociation); + + // Then + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + // Verify store was called + const mockStore = (SessionStorageGeographicStore as any)(); + expect(mockStore.create).toHaveBeenCalledWith(newAssociation); + + // Verify cache invalidation + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: QUERY_KEY_PATTERNS.GEO_ASSOCIATION_BY_USER(TEST_IDS.USER_ID), + }); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: QUERY_KEY_PATTERNS.GEO_ASSOCIATION_BY_GEOGRAPHY( + `${GEO_CONSTANTS.COUNTRY_US}-${GEO_CONSTANTS.REGION_CA}` + ), + }); + + // Verify cache update + expect(queryClient.setQueryData).toHaveBeenCalledWith( + QUERY_KEY_PATTERNS.GEO_ASSOCIATION_SPECIFIC( + TEST_IDS.USER_ID, + `${GEO_CONSTANTS.COUNTRY_US}-${GEO_CONSTANTS.REGION_CA}` + ), + mockUserGeographicAssociation + ); + }); + + test('given subnational association when created then updates cache with full identifier', async () => { + // Given + const subnationalAssociation = { + ...mockUserGeographicAssociation, + geographyType: GEO_CONSTANTS.TYPE_SUBNATIONAL, + geographyIdentifier: `${GEO_CONSTANTS.COUNTRY_US}-${GEO_CONSTANTS.REGION_CA}`, + regionCode: GEO_CONSTANTS.REGION_CA, + regionType: GEO_CONSTANTS.REGION_TYPE_STATE, + } as const; + + const mockStore = (SessionStorageGeographicStore as any)(); + mockStore.create.mockResolvedValue(subnationalAssociation); + const { result } = renderHook(() => useCreateGeographicAssociation(), { wrapper }); + + // When + await result.current.mutateAsync(subnationalAssociation); + + // Then + expect(queryClient.setQueryData).toHaveBeenCalledWith( + QUERY_KEY_PATTERNS.GEO_ASSOCIATION_SPECIFIC( + TEST_IDS.USER_ID, + `${GEO_CONSTANTS.COUNTRY_US}-${GEO_CONSTANTS.REGION_CA}` + ), + subnationalAssociation + ); + }); + + test('given creation fails when creating then returns error', async () => { + // Given + const error = new Error('Creation failed'); + const mockStore = (SessionStorageGeographicStore as any)(); + mockStore.create.mockRejectedValue(error); + const { result } = renderHook(() => useCreateGeographicAssociation(), { wrapper }); + + // When/Then + await expect( + result.current.mutateAsync({ + id: TEST_IDS.GEOGRAPHY_ID, + userId: TEST_IDS.USER_ID, + countryCode: GEO_CONSTANTS.COUNTRY_US, + geographyType: GEO_CONSTANTS.TYPE_NATIONAL, + geographyIdentifier: GEO_CONSTANTS.COUNTRY_US, + label: TEST_LABELS.GEOGRAPHY, + }) + ).rejects.toThrow('Creation failed'); + + // Cache should not be updated + expect(queryClient.setQueryData).not.toHaveBeenCalled(); + }); + + test('given multiple associations created then each updates cache independently', async () => { + // Given + const { result } = renderHook(() => useCreateGeographicAssociation(), { wrapper }); + + const association1 = { + id: TEST_IDS.GEOGRAPHY_ID, + userId: TEST_IDS.USER_ID, + countryCode: GEO_CONSTANTS.COUNTRY_US, + geographyType: GEO_CONSTANTS.TYPE_NATIONAL, + geographyIdentifier: GEO_CONSTANTS.COUNTRY_US, + label: TEST_LABELS.GEOGRAPHY, + } as const; + + const association2 = { + id: TEST_IDS.GEOGRAPHY_ID_2, + userId: TEST_IDS.USER_ID, + countryCode: GEO_CONSTANTS.COUNTRY_UK, + geographyType: GEO_CONSTANTS.TYPE_NATIONAL, + geographyIdentifier: GEO_CONSTANTS.COUNTRY_UK, + label: TEST_LABELS.GEOGRAPHY_2, + } as const; + + // When + const mockStore = (SessionStorageGeographicStore as any)(); + await result.current.mutateAsync(association1); + mockStore.create.mockResolvedValue({ + ...mockUserGeographicAssociation, + ...association2, + }); + await result.current.mutateAsync(association2); + + // Then + expect(mockStore.create).toHaveBeenCalledTimes(2); + expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(4); // 2 calls per creation + }); + }); + + describe('query configuration', () => { + test('given session storage mode then uses session storage config', async () => { + // When + const { result } = renderHook(() => useGeographicAssociationsByUser(TEST_IDS.USER_ID), { + wrapper, + }); + + // Then + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + // Session storage should have no stale time + const queryState = queryClient.getQueryState( + QUERY_KEY_PATTERNS.GEO_ASSOCIATION_BY_USER(TEST_IDS.USER_ID) + ); + expect(queryState).toBeDefined(); + }); + + test('given refetch called then fetches fresh data', async () => { + // Given + const { result } = renderHook(() => useGeographicAssociationsByUser(TEST_IDS.USER_ID), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + // Initial data should be the mock list + expect(result.current.data).toEqual(mockUserGeographicAssociationList); + + // When + const mockStore = (SessionStorageGeographicStore as any)(); + mockStore.findByUser.mockResolvedValue([]); + + // Force refetch + const refetchResult = await result.current.refetch(); + + // Then + expect(refetchResult.data).toEqual([]); + expect(mockStore.findByUser).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/app/src/tests/unit/hooks/useUserHousehold.test.tsx b/app/src/tests/unit/hooks/useUserHousehold.test.tsx new file mode 100644 index 00000000..3386b586 --- /dev/null +++ b/app/src/tests/unit/hooks/useUserHousehold.test.tsx @@ -0,0 +1,471 @@ +import React from 'react'; +import { configureStore } from '@reduxjs/toolkit'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { fetchHouseholdById } from '@/api/household'; +import { SessionStorageHouseholdStore } from '@/api/householdAssociation'; +// Now import everything +import { + useCreateHouseholdAssociation, + useHouseholdAssociation, + useHouseholdAssociationsByUser, + useUserHouseholds, + useUserHouseholdStore, +} from '@/hooks/useUserHousehold'; +import { + CONSOLE_MESSAGES, + createMockQueryClient, + GEO_CONSTANTS, + mockHouseholdMetadata, + mockReduxState, + mockUserHouseholdPopulation, + mockUserHouseholdPopulationList, + QUERY_KEY_PATTERNS, + setupMockConsole, + TEST_IDS, + TEST_LABELS, +} from '@/tests/fixtures/hooks/hooksMocks'; + +// Mock the stores first +vi.mock('@/api/householdAssociation', () => { + const mockStore = { + create: vi.fn(), + findByUser: vi.fn(), + findById: vi.fn(), + }; + return { + ApiHouseholdStore: vi.fn(() => mockStore), + SessionStorageHouseholdStore: vi.fn(() => mockStore), + }; +}); + +// Mock the household API +vi.mock('@/api/household', () => ({ + fetchHouseholdById: vi.fn(), +})); + +// Mock query config and keys +vi.mock('@/libs/queryConfig', () => ({ + queryConfig: { + api: { + staleTime: 5 * 60 * 1000, + cacheTime: 10 * 60 * 1000, + }, + sessionStorage: { + staleTime: 0, + cacheTime: 0, + }, + }, +})); + +vi.mock('@/libs/queryKeys', () => ({ + householdAssociationKeys: { + byUser: (userId: string) => ['household-associations', 'byUser', userId], + byHousehold: (id: string) => ['household-associations', 'byHousehold', id], + specific: (userId: string, id: string) => ['household-associations', 'specific', userId, id], + }, + householdKeys: { + all: ['households'], + byId: (id: string) => ['households', 'byId', id], + }, +})); + +describe('useUserHousehold hooks', () => { + let queryClient: QueryClient; + let consoleMocks: ReturnType; + let store: any; + + beforeEach(() => { + vi.clearAllMocks(); + queryClient = createMockQueryClient(); + consoleMocks = setupMockConsole(); + + // Create Redux store for useUserHouseholds + store = configureStore({ + reducer: { + metadata: () => mockReduxState.metadata, + }, + }); + + // Get the mock store instance + const mockStore = + (SessionStorageHouseholdStore as any).mock.results[0]?.value || + (SessionStorageHouseholdStore as any)(); + + // Set default mock implementations + mockStore.create.mockResolvedValue(mockUserHouseholdPopulation); + mockStore.findByUser.mockResolvedValue(mockUserHouseholdPopulationList); + mockStore.findById.mockResolvedValue(mockUserHouseholdPopulation); + (fetchHouseholdById as any).mockResolvedValue(mockHouseholdMetadata); + }); + + afterEach(() => { + consoleMocks.restore(); + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + describe('useUserHouseholdStore', () => { + test('given user not logged in then returns session storage store', () => { + // When + const { result } = renderHook(() => useUserHouseholdStore()); + + // Then + expect(result.current).toBeDefined(); + expect(result.current.create).toBeDefined(); + expect(result.current.findByUser).toBeDefined(); + expect(result.current.findById).toBeDefined(); + }); + }); + + describe('useHouseholdAssociationsByUser', () => { + test('given valid user ID when fetching then returns household list', async () => { + // Given + const userId = TEST_IDS.USER_ID; + + // When + const { result } = renderHook(() => useHouseholdAssociationsByUser(userId), { wrapper }); + + // Then + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual(mockUserHouseholdPopulationList); + const mockStore = (SessionStorageHouseholdStore as any)(); + expect(mockStore.findByUser).toHaveBeenCalledWith(userId); + + // Verify console logs + expect(consoleMocks.consoleSpy.log).toHaveBeenCalledWith( + CONSOLE_MESSAGES.USER_ID_LOG, + userId + ); + expect(consoleMocks.consoleSpy.log).toHaveBeenCalledWith( + CONSOLE_MESSAGES.STORE_LOG, + expect.any(Object) + ); + }); + + test('given empty user ID when fetching then still attempts fetch', async () => { + // When + const { result } = renderHook(() => useHouseholdAssociationsByUser(''), { wrapper }); + + // Then + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + const mockStore = (SessionStorageHouseholdStore as any)(); + expect(mockStore.findByUser).toHaveBeenCalledWith(''); + }); + + test('given store error when fetching then returns error state', async () => { + // Given + const error = new Error('Failed to fetch'); + const mockStore = (SessionStorageHouseholdStore as any)(); + mockStore.findByUser.mockRejectedValue(error); + + // When + const { result } = renderHook(() => useHouseholdAssociationsByUser(TEST_IDS.USER_ID), { + wrapper, + }); + + // Then + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(error); + }); + }); + + describe('useHouseholdAssociation', () => { + test('given valid IDs when fetching specific association then returns data', async () => { + // Given + const userId = TEST_IDS.USER_ID; + const householdId = TEST_IDS.HOUSEHOLD_ID; + + // When + const { result } = renderHook(() => useHouseholdAssociation(userId, householdId), { + wrapper, + }); + + // Then + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual(mockUserHouseholdPopulation); + const mockStore = (SessionStorageHouseholdStore as any)(); + expect(mockStore.findById).toHaveBeenCalledWith(userId, householdId); + }); + + test('given non-existent association when fetching then returns null', async () => { + // Given + const mockStore = (SessionStorageHouseholdStore as any)(); + mockStore.findById.mockResolvedValue(null); + + // When + const { result } = renderHook( + () => useHouseholdAssociation(TEST_IDS.USER_ID, 'non-existent'), + { wrapper } + ); + + // Then + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toBeNull(); + }); + }); + + describe('useCreateHouseholdAssociation', () => { + test('given valid household when created then adds type and updates cache', async () => { + // Given + const newHousehold = { + id: TEST_IDS.HOUSEHOLD_ID, + householdId: TEST_IDS.HOUSEHOLD_ID, + userId: TEST_IDS.USER_ID, + label: TEST_LABELS.HOUSEHOLD, + }; + + const { result } = renderHook(() => useCreateHouseholdAssociation(), { wrapper }); + + // When + await result.current.mutateAsync(newHousehold); + + // Then + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + // Verify type was added + const mockStore = (SessionStorageHouseholdStore as any)(); + expect(mockStore.create).toHaveBeenCalledWith({ + ...newHousehold, + type: 'household', + }); + + // Verify console logs + expect(consoleMocks.consoleSpy.log).toHaveBeenCalledWith(CONSOLE_MESSAGES.HOUSEHOLD_LOG); + expect(consoleMocks.consoleSpy.log).toHaveBeenCalledWith(newHousehold); + expect(consoleMocks.consoleSpy.log).toHaveBeenCalledWith( + CONSOLE_MESSAGES.NEW_ASSOCIATION_LOG + ); + + // Verify cache invalidation + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: QUERY_KEY_PATTERNS.ASSOCIATION_BY_USER(TEST_IDS.USER_ID), + }); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: QUERY_KEY_PATTERNS.ASSOCIATION_BY_HOUSEHOLD(TEST_IDS.HOUSEHOLD_ID), + }); + + // Verify cache update + expect(queryClient.setQueryData).toHaveBeenCalledWith( + QUERY_KEY_PATTERNS.ASSOCIATION_SPECIFIC(TEST_IDS.USER_ID, TEST_IDS.HOUSEHOLD_ID), + mockUserHouseholdPopulation + ); + }); + + test('given creation fails when creating then returns error', async () => { + // Given + const error = new Error('Creation failed'); + const mockStore = (SessionStorageHouseholdStore as any)(); + mockStore.create.mockRejectedValue(error); + const { result } = renderHook(() => useCreateHouseholdAssociation(), { wrapper }); + + // When/Then + await expect( + result.current.mutateAsync({ + id: TEST_IDS.HOUSEHOLD_ID, + householdId: TEST_IDS.HOUSEHOLD_ID, + userId: TEST_IDS.USER_ID, + label: TEST_LABELS.HOUSEHOLD, + }) + ).rejects.toThrow('Creation failed'); + }); + }); + + describe('useUserHouseholds', () => { + test('given user with households when fetching then returns combined data', async () => { + // Given + const userId = TEST_IDS.USER_ID; + + // When + const { result } = renderHook(() => useUserHouseholds(userId), { wrapper }); + + // Then + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.data).toBeDefined(); + expect(result.current.data).toHaveLength(2); // Two households in mock list + + // Verify console logs + expect(consoleMocks.consoleSpy.log).toHaveBeenCalledWith( + CONSOLE_MESSAGES.ASSOCIATIONS_LOG, + mockUserHouseholdPopulationList + ); + expect(consoleMocks.consoleSpy.log).toHaveBeenCalledWith(CONSOLE_MESSAGES.HOUSEHOLD_IDS_LOG, [ + TEST_IDS.HOUSEHOLD_ID, + TEST_IDS.HOUSEHOLD_ID_2, + ]); + + // Verify each household has association and metadata + const firstHousehold = result.current.data![0]; + expect(firstHousehold.association).toEqual(mockUserHouseholdPopulationList[0]); + expect(firstHousehold.household).toEqual(mockHouseholdMetadata); + expect(firstHousehold.isLoading).toBe(false); + expect(firstHousehold.error).toBeNull(); + }); + + test('given user with no households when fetching then returns empty array', async () => { + // Given + const mockStore = (SessionStorageHouseholdStore as any)(); + mockStore.findByUser.mockResolvedValue([]); + + // When + const { result } = renderHook(() => useUserHouseholds(TEST_IDS.USER_ID), { wrapper }); + + // Then + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.data).toEqual([]); + expect(fetchHouseholdById).not.toHaveBeenCalled(); + }); + + test('given association fetch fails when fetching then returns error', async () => { + // Given + const error = new Error('Association fetch failed'); + const mockStore = (SessionStorageHouseholdStore as any)(); + mockStore.findByUser.mockRejectedValue(error); + + // When + const { result } = renderHook(() => useUserHouseholds(TEST_IDS.USER_ID), { wrapper }); + + // Then + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(error); + }); + + test('given household fetch fails when fetching then returns partial error', async () => { + // Given + (fetchHouseholdById as any) + .mockResolvedValueOnce(mockHouseholdMetadata) // First succeeds + .mockRejectedValueOnce(new Error('Household fetch failed')); // Second fails + + // When + const { result } = renderHook(() => useUserHouseholds(TEST_IDS.USER_ID), { wrapper }); + + // Then + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.data).toBeDefined(); + + // First household should have data + expect(result.current.data![0].household).toEqual(mockHouseholdMetadata); + + // Second household should have error + expect(result.current.data![1].error).toBeDefined(); + expect(result.current.data![1].isError).toBe(true); + }); + + test('given different country in metadata then uses correct country for fetch', async () => { + // Given + store = configureStore({ + reducer: { + metadata: () => ({ currentCountry: GEO_CONSTANTS.COUNTRY_UK }), + }, + }); + + const customWrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + // When + const { result } = renderHook(() => useUserHouseholds(TEST_IDS.USER_ID), { + wrapper: customWrapper, + }); + + // Then + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(fetchHouseholdById).toHaveBeenCalledWith(GEO_CONSTANTS.COUNTRY_UK, expect.any(String)); + }); + + test('given no country in metadata then defaults to us', async () => { + // Given + store = configureStore({ + reducer: { + metadata: () => ({ currentCountry: null }), + }, + }); + + const customWrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + // When + const { result } = renderHook(() => useUserHouseholds(TEST_IDS.USER_ID), { + wrapper: customWrapper, + }); + + // Then + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(fetchHouseholdById).toHaveBeenCalledWith(GEO_CONSTANTS.COUNTRY_US, expect.any(String)); + }); + + test('given associations without household IDs then filters them out', async () => { + // Given + const associationsWithNullId = [ + mockUserHouseholdPopulation, + { ...mockUserHouseholdPopulation, householdId: null }, + ]; + const mockStore = (SessionStorageHouseholdStore as any)(); + mockStore.findByUser.mockResolvedValue(associationsWithNullId); + + // When + const { result } = renderHook(() => useUserHouseholds(TEST_IDS.USER_ID), { wrapper }); + + // Then + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + // Should only have one household in result (the one with ID) + expect(result.current.data).toHaveLength(1); + // But fetchHouseholdById is called for both (including null) + expect(fetchHouseholdById).toHaveBeenCalledTimes(2); + expect(fetchHouseholdById).toHaveBeenCalledWith( + GEO_CONSTANTS.COUNTRY_US, + TEST_IDS.HOUSEHOLD_ID + ); + expect(fetchHouseholdById).toHaveBeenCalledWith(GEO_CONSTANTS.COUNTRY_US, null); + }); + }); +}); diff --git a/app/src/tests/unit/pages/Populations.page.test.tsx b/app/src/tests/unit/pages/Populations.page.test.tsx new file mode 100644 index 00000000..e7d63e7e --- /dev/null +++ b/app/src/tests/unit/pages/Populations.page.test.tsx @@ -0,0 +1,482 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { render, screen, userEvent, waitFor } from '@test-utils'; +import { Provider } from 'react-redux'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { PopulationCreationFlow } from '@/flows/populationCreationFlow'; +import { useGeographicAssociationsByUser } from '@/hooks/useUserGeographic'; +import { useUserHouseholds } from '@/hooks/useUserHousehold'; +// Now import everything else +import PopulationsPage from '@/pages/Populations.page'; +import { + createEmptyDataState, + createErrorState, + createLoadingState, + mockGeographicAssociationsData, + mockUserHouseholdsData, + POPULATION_COLUMNS, + POPULATION_DETAILS, + POPULATION_GEO, + POPULATION_LABELS, + POPULATION_TEST_IDS, + setupMockConsole, +} from '@/tests/fixtures/pages/populationsMocks'; + +// Mock the hooks first +vi.mock('@/hooks/useUserHousehold', () => ({ + useUserHouseholds: vi.fn(), +})); + +vi.mock('@/hooks/useUserGeographic', () => ({ + useGeographicAssociationsByUser: vi.fn(), +})); + +// Mock the constants +vi.mock('@/constants', () => ({ + MOCK_USER_ID: 'test-user-123', + BASE_URL: 'https://api.test.com', +})); + +describe('PopulationsPage', () => { + let store: any; + let consoleMocks: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + consoleMocks = setupMockConsole(); + + // Create a mock store with flow reducer + store = configureStore({ + reducer: { + flow: (state = { current: null }, action: any) => { + if (action.type === 'flow/setFlow') { + return { ...state, current: action.payload }; + } + return state; + }, + }, + }); + + // Mock dispatch + vi.spyOn(store, 'dispatch'); + + // Set default mock implementations + (useUserHouseholds as any).mockReturnValue({ + data: mockUserHouseholdsData, + isLoading: false, + isError: false, + error: null, + }); + + (useGeographicAssociationsByUser as any).mockReturnValue({ + data: mockGeographicAssociationsData, + isLoading: false, + isError: false, + error: null, + }); + }); + + afterEach(() => { + consoleMocks.restore(); + }); + + const renderPage = () => { + return render( + + + + ); + }; + + describe('initial render', () => { + test('given page loads then displays title and subtitle', () => { + // When + renderPage(); + + // Then + expect(screen.getByText(POPULATION_LABELS.PAGE_TITLE)).toBeInTheDocument(); + expect(screen.getByText(POPULATION_LABELS.PAGE_SUBTITLE)).toBeInTheDocument(); + }); + + test('given page loads then displays build population button', () => { + // When + renderPage(); + + // Then + expect( + screen.getByRole('button', { name: new RegExp(POPULATION_LABELS.BUILD_BUTTON, 'i') }) + ).toBeInTheDocument(); + }); + + test('given page loads then fetches user data with correct user ID', () => { + // When + renderPage(); + + // Then + expect(useUserHouseholds).toHaveBeenCalledWith(POPULATION_TEST_IDS.USER_ID); + expect(useGeographicAssociationsByUser).toHaveBeenCalledWith(POPULATION_TEST_IDS.USER_ID); + }); + }); + + describe('data display', () => { + test('given household data available then displays household populations', () => { + // When + renderPage(); + + // Then + expect(screen.getByText(POPULATION_LABELS.HOUSEHOLD_1)).toBeInTheDocument(); + expect(screen.getByText(POPULATION_LABELS.HOUSEHOLD_2)).toBeInTheDocument(); + }); + + test('given geographic data available then displays geographic populations', () => { + // When + renderPage(); + + // Then + expect(screen.getByText(POPULATION_LABELS.GEOGRAPHIC_1)).toBeInTheDocument(); + expect(screen.getByText(POPULATION_LABELS.GEOGRAPHIC_2)).toBeInTheDocument(); + }); + + test('given household with people then displays correct person count', () => { + // When + renderPage(); + + // Then + expect(screen.getByText(POPULATION_DETAILS.PERSON_PLURAL(2))).toBeInTheDocument(); + expect(screen.getByText(POPULATION_DETAILS.PERSON_SINGULAR)).toBeInTheDocument(); + }); + + test('given geographic association then displays scope details', () => { + // When + renderPage(); + + // Then + expect(screen.getByText(POPULATION_DETAILS.SUBNATIONAL)).toBeInTheDocument(); + expect(screen.getByText(POPULATION_DETAILS.NATIONAL)).toBeInTheDocument(); + expect(screen.getByText(POPULATION_GEO.COUNTRY_US.toUpperCase())).toBeInTheDocument(); + expect(screen.getByText(POPULATION_GEO.COUNTRY_UK.toUpperCase())).toBeInTheDocument(); + }); + + test('given subnational geography then displays region details', () => { + // When + renderPage(); + + // Then + expect( + screen.getByText(`${POPULATION_DETAILS.STATE_PREFIX} ${POPULATION_GEO.STATE_CA}`) + ).toBeInTheDocument(); + }); + + test('given created dates then displays formatted dates', () => { + // When + renderPage(); + + // Then + const date1 = new Date(POPULATION_TEST_IDS.TIMESTAMP_1).toLocaleDateString(); + const date2 = new Date(POPULATION_TEST_IDS.TIMESTAMP_2).toLocaleDateString(); + + // Use getAllByText since dates might appear multiple times + const date1Elements = screen.getAllByText(date1); + const date2Elements = screen.getAllByText(date2); + + expect(date1Elements.length).toBeGreaterThan(0); + expect(date2Elements.length).toBeGreaterThan(0); + }); + + test('given no data then displays empty state', () => { + // Given + const emptyState = createEmptyDataState(); + (useUserHouseholds as any).mockReturnValue(emptyState.household); + (useGeographicAssociationsByUser as any).mockReturnValue(emptyState.geographic); + + // When + renderPage(); + + // Then - Check that no population items are displayed + expect(screen.queryByText(POPULATION_LABELS.HOUSEHOLD_1)).not.toBeInTheDocument(); + expect(screen.queryByText(POPULATION_LABELS.GEOGRAPHIC_1)).not.toBeInTheDocument(); + }); + }); + + describe('loading states', () => { + test('given household data loading then shows loading state', () => { + // Given + const loadingState = createLoadingState(true, false); + (useUserHouseholds as any).mockReturnValue(loadingState.household); + (useGeographicAssociationsByUser as any).mockReturnValue(loadingState.geographic); + + // When + renderPage(); + + // Then - Look for the Loader component by its role or test the loading state + const loaderElement = document.querySelector('.mantine-Loader-root'); + expect(loaderElement).toBeInTheDocument(); + }); + + test('given geographic data loading then shows loading state', () => { + // Given + const loadingState = createLoadingState(false, true); + (useUserHouseholds as any).mockReturnValue(loadingState.household); + (useGeographicAssociationsByUser as any).mockReturnValue(loadingState.geographic); + + // When + renderPage(); + + // Then - Look for the Loader component + const loaderElement = document.querySelector('.mantine-Loader-root'); + expect(loaderElement).toBeInTheDocument(); + }); + + test('given both loading then shows single loading state', () => { + // Given + const loadingState = createLoadingState(true, true); + (useUserHouseholds as any).mockReturnValue(loadingState.household); + (useGeographicAssociationsByUser as any).mockReturnValue(loadingState.geographic); + + // When + renderPage(); + + // Then - Check for single loader + const loaderElements = document.querySelectorAll('.mantine-Loader-root'); + expect(loaderElements).toHaveLength(1); + }); + }); + + describe('error states', () => { + test('given household fetch error then displays error message', () => { + // Given + const errorState = createErrorState(true, false); + (useUserHouseholds as any).mockReturnValue(errorState.household); + (useGeographicAssociationsByUser as any).mockReturnValue(errorState.geographic); + + // When + renderPage(); + + // Then - Look for error text containing "Error:" + expect(screen.getByText(/Error:/)).toBeInTheDocument(); + }); + + test('given geographic fetch error then displays error message', () => { + // Given + const errorState = createErrorState(false, true); + (useUserHouseholds as any).mockReturnValue(errorState.household); + (useGeographicAssociationsByUser as any).mockReturnValue(errorState.geographic); + + // When + renderPage(); + + // Then - Look for error text containing "Error:" + expect(screen.getByText(/Error:/)).toBeInTheDocument(); + }); + + test('given both fetch errors then displays single error message', () => { + // Given + const errorState = createErrorState(true, true); + (useUserHouseholds as any).mockReturnValue(errorState.household); + (useGeographicAssociationsByUser as any).mockReturnValue(errorState.geographic); + + // When + renderPage(); + + // Then - Check for single error message + const errorElements = screen.getAllByText(/Error:/); + expect(errorElements).toHaveLength(1); + }); + }); + + describe('user interactions', () => { + test('given user clicks build population then dispatches flow action', async () => { + // Given + const user = userEvent.setup(); + renderPage(); + + // When + const buildButton = screen.getByRole('button', { + name: new RegExp(POPULATION_LABELS.BUILD_BUTTON, 'i'), + }); + await user.click(buildButton); + + // Then + expect(store.dispatch).toHaveBeenCalledWith({ + type: 'flow/setFlow', + payload: PopulationCreationFlow, + }); + }); + + // NOTE: This behavior is a placeholder + test('given user clicks more filters then logs action', async () => { + // Given + userEvent.setup(); + renderPage(); + + // When - The button is disabled in the component + const moreFiltersButton = screen.getByRole('button', { name: /filter/i }); + + // Then - Verify button is disabled (can't be clicked) + expect(moreFiltersButton).toBeDisabled(); + }); + + test('given user searches then filters populations', async () => { + // Given + userEvent.setup(); + renderPage(); + const searchInput = screen.getByPlaceholderText(/search/i); + + // When - Note that search is disabled in the component + // The input is disabled, so we can't type into it + + // Then - Just verify the input exists and is disabled + expect(searchInput).toBeDisabled(); + }); + + test('given user selects population then updates selection state', async () => { + // Given + const user = userEvent.setup(); + renderPage(); + + // When - Find and click a checkbox (assuming the IngredientReadView renders checkboxes) + const checkboxes = screen.getAllByRole('checkbox'); + if (checkboxes.length > 0) { + await user.click(checkboxes[0]); + + // Then + await waitFor(() => { + expect(checkboxes[0]).toBeChecked(); + }); + } + }); + }); + + describe('data transformation', () => { + test('given household without label then uses default naming', () => { + // Given + const dataWithoutLabel = [ + { + ...mockUserHouseholdsData[0], + association: { + ...mockUserHouseholdsData[0].association, + label: undefined, + }, + }, + ]; + + (useUserHouseholds as any).mockReturnValue({ + data: dataWithoutLabel, + isLoading: false, + isError: false, + error: null, + }); + + // When + renderPage(); + + // Then + expect( + screen.getByText(`Household #${POPULATION_TEST_IDS.HOUSEHOLD_ID_1}`) + ).toBeInTheDocument(); + }); + + test('given household without created date then displays just now', () => { + // Given + const dataWithoutDate = [ + { + ...mockUserHouseholdsData[0], + association: { + ...mockUserHouseholdsData[0].association, + createdAt: undefined, + }, + }, + ]; + + (useUserHouseholds as any).mockReturnValue({ + data: dataWithoutDate, + isLoading: false, + isError: false, + error: null, + }); + + // When + renderPage(); + + // Then + expect(screen.getByText('Just now')).toBeInTheDocument(); + }); + + test('given household with no people then displays zero count', () => { + // Given + const dataWithNoPeople = [ + { + ...mockUserHouseholdsData[0], + household: { + ...mockUserHouseholdsData[0].household, + household_json: { + people: {}, + families: {}, + }, + }, + }, + ]; + + (useUserHouseholds as any).mockReturnValue({ + data: dataWithNoPeople, + isLoading: false, + isError: false, + error: null, + }); + + // When + renderPage(); + + // Then + expect(screen.getByText('0 persons')).toBeInTheDocument(); + }); + + test('given mixed data then displays both household and geographic populations', () => { + // When + renderPage(); + + // Then - Verify both types are rendered + expect(screen.getByText(POPULATION_LABELS.HOUSEHOLD_1)).toBeInTheDocument(); + expect(screen.getByText(POPULATION_LABELS.GEOGRAPHIC_1)).toBeInTheDocument(); + + // Verify different detail types + expect(screen.getByText(POPULATION_DETAILS.PERSON_PLURAL(2))).toBeInTheDocument(); + expect(screen.getByText(POPULATION_DETAILS.SUBNATIONAL)).toBeInTheDocument(); + }); + }); + + describe('column configuration', () => { + test('given page renders then displays correct column headers', () => { + // When + renderPage(); + + // Then + expect(screen.getByText(POPULATION_COLUMNS.NAME)).toBeInTheDocument(); + expect(screen.getByText(POPULATION_COLUMNS.DATE)).toBeInTheDocument(); + expect(screen.getByText(POPULATION_COLUMNS.DETAILS)).toBeInTheDocument(); + expect(screen.getByText(POPULATION_COLUMNS.CONNECTIONS)).toBeInTheDocument(); + }); + + test('given household data then displays connections placeholders', () => { + // When + renderPage(); + + // Then + // Check for multiple occurrences since there are multiple households + const simulations = screen.getAllByText(POPULATION_DETAILS.SAMPLE_SIMULATION); + const reports = screen.getAllByText(POPULATION_DETAILS.SAMPLE_REPORT); + + expect(simulations.length).toBeGreaterThan(0); + expect(reports.length).toBeGreaterThan(0); + }); + + test('given geographic data then displays available for simulations', () => { + // When + renderPage(); + + // Then + // Check for multiple occurrences since there are multiple geographic associations + const available = screen.getAllByText(POPULATION_DETAILS.AVAILABLE_FOR_SIMULATIONS); + expect(available.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/app/src/tests/unit/reducers/populationReducer.test.ts b/app/src/tests/unit/reducers/populationReducer.test.ts new file mode 100644 index 00000000..6502ac79 --- /dev/null +++ b/app/src/tests/unit/reducers/populationReducer.test.ts @@ -0,0 +1,630 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import populationReducer, { + clearPopulation, + initializeHousehold, + markPopulationAsCreated, + setGeography, + setHousehold, + updatePopulationId, + updatePopulationLabel, +} from '@/reducers/populationReducer'; +import { + createMockGeography, + createMockHouseholdForCountry, + expectStateToMatch, + mockGeography, + mockGeographyNational, + mockHousehold, + mockHouseholdUK, + mockInitialState, + mockStateCreated, + mockStateWithGeography, + mockStateWithHousehold, + POPULATION_COUNTRIES, + POPULATION_IDS, + POPULATION_LABELS, + POPULATION_REGIONS, + POPULATION_YEARS, + resetAllMocks, +} from '@/tests/fixtures/reducers/populationReducerMocks'; +import { HouseholdBuilder } from '@/utils/HouseholdBuilder'; + +// Mock HouseholdBuilder before any imports that use it +vi.mock('@/utils/HouseholdBuilder', () => { + return { + HouseholdBuilder: vi.fn(), + }; +}); + +// Set up the mock implementation +const mockBuildMethod = vi.fn(); +(HouseholdBuilder as any).mockImplementation((countryId: string) => { + mockBuildMethod.mockReturnValue(createMockHouseholdForCountry(countryId)); + return { + build: mockBuildMethod, + }; +}); + +describe('populationReducer', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetAllMocks(); + }); + + describe('initial state', () => { + test('given no state when reducer initialized then returns default state', () => { + // When + const state = populationReducer(undefined, { type: 'unknown' }); + + // Then + expectStateToMatch(state, mockInitialState); + }); + + test('given initial state then has correct default values', () => { + // When + const state = populationReducer(undefined, { type: 'unknown' }); + + // Then + expect(state.label).toBeNull(); + expect(state.isCreated).toBe(false); + expect(state.household).toBeNull(); + expect(state.geography).toBeNull(); + }); + }); + + describe('clearPopulation action', () => { + test('given state with household when clearPopulation then resets to initial state', () => { + // Given + const initialState = { ...mockStateWithHousehold }; + + // When + const state = populationReducer(initialState, clearPopulation()); + + // Then + expectStateToMatch(state, mockInitialState); + }); + + test('given state with geography when clearPopulation then resets to initial state', () => { + // Given + const initialState = { ...mockStateWithGeography }; + + // When + const state = populationReducer(initialState, clearPopulation()); + + // Then + expectStateToMatch(state, mockInitialState); + }); + + test('given created state when clearPopulation then resets isCreated flag', () => { + // Given + const initialState = { ...mockStateCreated }; + + // When + const state = populationReducer(initialState, clearPopulation()); + + // Then + expect(state.isCreated).toBe(false); + }); + + test('given state with label when clearPopulation then clears label', () => { + // Given + const initialState = { + ...mockInitialState, + label: POPULATION_LABELS.DEFAULT, + }; + + // When + const state = populationReducer(initialState, clearPopulation()); + + // Then + expect(state.label).toBeNull(); + }); + }); + + describe('updatePopulationId action', () => { + test('given state with household when updatePopulationId then updates household id', () => { + // Given + const initialState = { ...mockStateWithHousehold }; + + // When + const state = populationReducer( + initialState, + updatePopulationId(POPULATION_IDS.HOUSEHOLD_ID_NEW) + ); + + // Then + expect(state.household?.id).toBe(POPULATION_IDS.HOUSEHOLD_ID_NEW); + expect(state.geography).toBeNull(); + }); + + test('given state with geography when updatePopulationId then updates geography id', () => { + // Given + const initialState = { ...mockStateWithGeography }; + + // When + const state = populationReducer( + initialState, + updatePopulationId(POPULATION_IDS.GEOGRAPHY_ID_NEW) + ); + + // Then + expect(state.geography?.id).toBe(POPULATION_IDS.GEOGRAPHY_ID_NEW); + expect(state.household).toBeNull(); + }); + + test('given empty state when updatePopulationId then does nothing', () => { + // Given + const initialState = { ...mockInitialState }; + + // When + const state = populationReducer( + initialState, + updatePopulationId(POPULATION_IDS.HOUSEHOLD_ID_NEW) + ); + + // Then + expect(state.household).toBeNull(); + expect(state.geography).toBeNull(); + }); + + test('given state with both null when updatePopulationId then state unchanged', () => { + // Given + const initialState = { + ...mockInitialState, + household: null, + geography: null, + }; + + // When + const state = populationReducer(initialState, updatePopulationId('any-id')); + + // Then + expectStateToMatch(state, initialState); + }); + }); + + describe('updatePopulationLabel action', () => { + test('given any state when updatePopulationLabel then updates label', () => { + // Given + const initialState = { ...mockInitialState }; + + // When + const state = populationReducer( + initialState, + updatePopulationLabel(POPULATION_LABELS.UPDATED) + ); + + // Then + expect(state.label).toBe(POPULATION_LABELS.UPDATED); + }); + + test('given existing label when updatePopulationLabel then replaces label', () => { + // Given + const initialState = { + ...mockInitialState, + label: POPULATION_LABELS.DEFAULT, + }; + + // When + const state = populationReducer( + initialState, + updatePopulationLabel(POPULATION_LABELS.UPDATED) + ); + + // Then + expect(state.label).toBe(POPULATION_LABELS.UPDATED); + }); + + test('given state with household when updatePopulationLabel then preserves household', () => { + // Given + const initialState = { ...mockStateWithHousehold }; + + // When + const state = populationReducer( + initialState, + updatePopulationLabel(POPULATION_LABELS.UPDATED) + ); + + // Then + expect(state.label).toBe(POPULATION_LABELS.UPDATED); + expect(state.household).toEqual(mockHousehold); + }); + + test('given empty string when updatePopulationLabel then sets empty string', () => { + // Given + const initialState = { ...mockInitialState }; + + // When + const state = populationReducer(initialState, updatePopulationLabel('')); + + // Then + expect(state.label).toBe(''); + }); + }); + + describe('markPopulationAsCreated action', () => { + test('given not created state when markPopulationAsCreated then sets isCreated true', () => { + // Given + const initialState = { + ...mockInitialState, + isCreated: false, + }; + + // When + const state = populationReducer(initialState, markPopulationAsCreated()); + + // Then + expect(state.isCreated).toBe(true); + }); + + test('given already created state when markPopulationAsCreated then remains true', () => { + // Given + const initialState = { + ...mockInitialState, + isCreated: true, + }; + + // When + const state = populationReducer(initialState, markPopulationAsCreated()); + + // Then + expect(state.isCreated).toBe(true); + }); + + test('given state with data when markPopulationAsCreated then preserves all data', () => { + // Given + const initialState = { ...mockStateWithHousehold }; + + // When + const state = populationReducer(initialState, markPopulationAsCreated()); + + // Then + expect(state.isCreated).toBe(true); + expect(state.household).toEqual(mockHousehold); + expect(state.label).toBe(POPULATION_LABELS.HOUSEHOLD); + }); + }); + + describe('setHousehold action', () => { + test('given empty state when setHousehold then sets household and clears geography', () => { + // Given + const initialState = { ...mockInitialState }; + + // When + const state = populationReducer(initialState, setHousehold(mockHousehold)); + + // Then + expect(state.household).toEqual(mockHousehold); + expect(state.geography).toBeNull(); + }); + + test('given state with geography when setHousehold then replaces geography with household', () => { + // Given + const initialState = { ...mockStateWithGeography }; + + // When + const state = populationReducer(initialState, setHousehold(mockHousehold)); + + // Then + expect(state.household).toEqual(mockHousehold); + expect(state.geography).toBeNull(); + }); + + test('given state with existing household when setHousehold then replaces household', () => { + // Given + const initialState = { ...mockStateWithHousehold }; + + // When + const state = populationReducer(initialState, setHousehold(mockHouseholdUK)); + + // Then + expect(state.household).toEqual(mockHouseholdUK); + expect(state.household?.countryId).toBe(POPULATION_COUNTRIES.UK); + }); + + test('given state with label when setHousehold then preserves label', () => { + // Given + const initialState = { + ...mockInitialState, + label: POPULATION_LABELS.DEFAULT, + }; + + // When + const state = populationReducer(initialState, setHousehold(mockHousehold)); + + // Then + expect(state.label).toBe(POPULATION_LABELS.DEFAULT); + expect(state.household).toEqual(mockHousehold); + }); + + test('given created state when setHousehold then preserves isCreated flag', () => { + // Given + const initialState = { + ...mockInitialState, + isCreated: true, + }; + + // When + const state = populationReducer(initialState, setHousehold(mockHousehold)); + + // Then + expect(state.isCreated).toBe(true); + expect(state.household).toEqual(mockHousehold); + }); + }); + + describe('initializeHousehold action', () => { + test('given US country when initializeHousehold then creates US household', () => { + // Given + const initialState = { ...mockInitialState }; + + // When + const state = populationReducer( + initialState, + initializeHousehold({ countryId: POPULATION_COUNTRIES.US }) + ); + + // Then + expect(state.household).toBeDefined(); + expect(state.household?.countryId).toBe(POPULATION_COUNTRIES.US); + }); + + test('given UK country when initializeHousehold then creates UK household', () => { + // Given + const initialState = { ...mockInitialState }; + + // When + const state = populationReducer( + initialState, + initializeHousehold({ countryId: POPULATION_COUNTRIES.UK }) + ); + + // Then + expect(state.household).toBeDefined(); + expect(state.household?.countryId).toBe(POPULATION_COUNTRIES.UK); + }); + + test('given custom year when initializeHousehold then uses provided year', () => { + // Given + const initialState = { ...mockInitialState }; + + // When + const state = populationReducer( + initialState, + initializeHousehold({ + countryId: POPULATION_COUNTRIES.US, + year: POPULATION_YEARS.FUTURE, + }) + ); + + // Then + expect(state.household).toBeDefined(); + expect(state.household?.countryId).toBe(POPULATION_COUNTRIES.US); + }); + + test('given no year when initializeHousehold then uses default year', () => { + const DEFAULT_YEAR = '2024'; + // Given + const initialState = { ...mockInitialState }; + (HouseholdBuilder as any).mockClear(); + + // When + const state = populationReducer( + initialState, + initializeHousehold({ countryId: POPULATION_COUNTRIES.US }) + ); + + // Then + expect(state.household).toBeDefined(); + // Verify HouseholdBuilder was called with default year '2024' + expect(HouseholdBuilder).toHaveBeenCalledWith(POPULATION_COUNTRIES.US, DEFAULT_YEAR); + }); + + test('given existing household when initializeHousehold then replaces household', () => { + // Given + const initialState = { ...mockStateWithHousehold }; + + // When + const state = populationReducer( + initialState, + initializeHousehold({ countryId: POPULATION_COUNTRIES.CA }) + ); + + // Then + expect(state.household).toBeDefined(); + expect(state.household?.countryId).toBe(POPULATION_COUNTRIES.CA); + }); + + test('given state with geography when initializeHousehold then preserves geography', () => { + // Given + const initialState = { ...mockStateWithGeography }; + + // When + const state = populationReducer( + initialState, + initializeHousehold({ countryId: POPULATION_COUNTRIES.US }) + ); + + // Then + expect(state.household).toBeDefined(); + expect(state.geography).toEqual(mockGeography); + }); + }); + + describe('setGeography action', () => { + test('given empty state when setGeography then sets geography and clears household', () => { + // Given + const initialState = { ...mockInitialState }; + + // When + const state = populationReducer(initialState, setGeography(mockGeography)); + + // Then + expect(state.geography).toEqual(mockGeography); + expect(state.household).toBeNull(); + }); + + test('given state with household when setGeography then replaces household with geography', () => { + // Given + const initialState = { ...mockStateWithHousehold }; + + // When + const state = populationReducer(initialState, setGeography(mockGeography)); + + // Then + expect(state.geography).toEqual(mockGeography); + expect(state.household).toBeNull(); + }); + + test('given state with existing geography when setGeography then replaces geography', () => { + // Given + const initialState = { ...mockStateWithGeography }; + + // When + const state = populationReducer(initialState, setGeography(mockGeographyNational)); + + // Then + expect(state.geography).toEqual(mockGeographyNational); + expect(state.geography?.scope).toBe('national'); + }); + + test('given state with label when setGeography then preserves label', () => { + // Given + const initialState = { + ...mockInitialState, + label: POPULATION_LABELS.DEFAULT, + }; + + // When + const state = populationReducer(initialState, setGeography(mockGeography)); + + // Then + expect(state.label).toBe(POPULATION_LABELS.DEFAULT); + expect(state.geography).toEqual(mockGeography); + }); + + test('given created state when setGeography then preserves isCreated flag', () => { + // Given + const initialState = { + ...mockInitialState, + isCreated: true, + }; + + // When + const state = populationReducer(initialState, setGeography(mockGeography)); + + // Then + expect(state.isCreated).toBe(true); + expect(state.geography).toEqual(mockGeography); + }); + + test('given subnational geography when setGeography then stores with region details', () => { + // Given + const initialState = { ...mockInitialState }; + const subnationalGeo = createMockGeography( + POPULATION_COUNTRIES.US, + 'subnational', + POPULATION_REGIONS.CALIFORNIA + ); + + // When + const state = populationReducer(initialState, setGeography(subnationalGeo)); + + // Then + expect(state.geography).toEqual(subnationalGeo); + expect(state.geography?.geographyId).toBe( + `${POPULATION_COUNTRIES.US}-${POPULATION_REGIONS.CALIFORNIA}` + ); + expect(state.geography?.scope).toBe('subnational'); + }); + }); + + describe('combined actions', () => { + test('given multiple actions when applied sequentially then state updates correctly', () => { + // Given + let state = { ...mockInitialState }; + + // When - Apply multiple actions + state = populationReducer(state, setHousehold(mockHousehold)); + state = populationReducer(state, updatePopulationLabel(POPULATION_LABELS.DEFAULT)); + state = populationReducer(state, updatePopulationId(POPULATION_IDS.HOUSEHOLD_ID_NEW)); + state = populationReducer(state, markPopulationAsCreated()); + + // Then + expect(state.household?.id).toBe(POPULATION_IDS.HOUSEHOLD_ID_NEW); + expect(state.label).toBe(POPULATION_LABELS.DEFAULT); + expect(state.isCreated).toBe(true); + expect(state.geography).toBeNull(); + }); + + test('given household then geography when setting both then only geography remains', () => { + // Given + let state = { ...mockInitialState }; + + // When + state = populationReducer(state, setHousehold(mockHousehold)); + expect(state.household).toEqual(mockHousehold); + + state = populationReducer(state, setGeography(mockGeography)); + + // Then + expect(state.geography).toEqual(mockGeography); + expect(state.household).toBeNull(); + }); + + test('given geography then household when setting both then only household remains', () => { + // Given + let state = { ...mockInitialState }; + + // When + state = populationReducer(state, setGeography(mockGeography)); + expect(state.geography).toEqual(mockGeography); + + state = populationReducer(state, setHousehold(mockHousehold)); + + // Then + expect(state.household).toEqual(mockHousehold); + expect(state.geography).toBeNull(); + }); + + test('given complete state when clearPopulation then resets everything', () => { + // Given + let state = { ...mockInitialState }; + + // Build up a complete state + state = populationReducer(state, setHousehold(mockHousehold)); + state = populationReducer(state, updatePopulationLabel(POPULATION_LABELS.DEFAULT)); + state = populationReducer(state, markPopulationAsCreated()); + + // When + state = populationReducer(state, clearPopulation()); + + // Then + expectStateToMatch(state, mockInitialState); + }); + }); + + describe('edge cases', () => { + test('given undefined payload when actions require payload then handles gracefully', () => { + // Given + const initialState = { ...mockStateWithHousehold }; + + // When - Pass undefined to updatePopulationId + const state = populationReducer(initialState, updatePopulationId(undefined as any)); + + // Then - Should update with undefined + expect(state.household?.id).toBeUndefined(); + }); + + test('given unknown action type when processed then returns unchanged state', () => { + // Given + const initialState = { ...mockStateWithHousehold }; + + // When + const state = populationReducer(initialState, { type: 'unknown/action' } as any); + + // Then + expectStateToMatch(state, initialState); + }); + }); +}); diff --git a/app/src/tests/unit/utils/HouseholdBuilder.test.ts b/app/src/tests/unit/utils/HouseholdBuilder.test.ts new file mode 100644 index 00000000..089dcace --- /dev/null +++ b/app/src/tests/unit/utils/HouseholdBuilder.test.ts @@ -0,0 +1,910 @@ +import { beforeEach, describe, expect, test } from 'vitest'; +import { + countGroupMembers, + countPeople, + COUNTRIES, + createEmptyHousehold, + createMockVariables, + createYearKeyedValue, + ENTITY_NAMES, + ERROR_MESSAGES, + EXPECTED_COUNTRY_ENTITIES, + GROUP_KEYS, + PERSON_AGES, + PERSON_NAMES, + VARIABLE_NAMES, + VARIABLE_VALUES, + verifyHouseholdStructure, + verifyPersonExists, + verifyPersonInGroup, + verifyPersonNotInAnyGroup, + verifyVariableValue, + YEARS, +} from '@/tests/fixtures/utils/householdBuilderMocks'; +import { HouseholdBuilder } from '@/utils/HouseholdBuilder'; + +describe('HouseholdBuilder', () => { + let builder: HouseholdBuilder; + + beforeEach(() => { + builder = new HouseholdBuilder(COUNTRIES.US, YEARS.CURRENT); + }); + + describe('constructor', () => { + test('given valid country and year when constructed then creates empty household', () => { + // When + const household = builder.build(); + + // Then + verifyHouseholdStructure(household, COUNTRIES.US); + expect(countPeople(household)).toBe(0); + }); + + test('given US country when constructed then includes US-specific entities', () => { + // When + const household = builder.build(); + + // Then + expect(household.householdData.families).toBeDefined(); + expect(household.householdData.taxUnits).toBeDefined(); + expect(household.householdData.spmUnits).toBeDefined(); + expect(household.householdData.maritalUnits).toBeDefined(); + expect(household.householdData.households).toBeDefined(); + }); + + test('given UK country when constructed then includes UK-specific entities', () => { + // Given + builder = new HouseholdBuilder(COUNTRIES.UK as any, YEARS.CURRENT); + + // When + const household = builder.build(); + + // Then + expect(household.householdData.benunits).toBeDefined(); + expect(household.householdData.households).toBeDefined(); + expect(household.householdData.families).toBeUndefined(); + expect(household.householdData.taxUnits).toBeUndefined(); + }); + + test('given Canada country when constructed then includes basic entities', () => { + // Given + builder = new HouseholdBuilder(COUNTRIES.CA as any, YEARS.CURRENT); + + // When + const household = builder.build(); + + // Then + expect(household.householdData.people).toBeDefined(); + expect(household.householdData.households).toBeDefined(); + expect(household.householdData.families).toBeUndefined(); + expect(household.householdData.benunits).toBeUndefined(); + }); + + test('given no year when constructed then uses default year', () => { + // Given + builder = new HouseholdBuilder(COUNTRIES.US); + const DEFAULT_YEAR = '2024'; + + // When + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + const household = builder.build(); + + // Then + const person = household.householdData.people[PERSON_NAMES.ADULT_1]; + expect(person.age[DEFAULT_YEAR]).toBe(PERSON_AGES.ADULT_DEFAULT); + }); + + test('given invalid year format when constructed then throws error', () => { + // When/Then + expect(() => new HouseholdBuilder(COUNTRIES.US, YEARS.INVALID)).toThrow( + ERROR_MESSAGES.INVALID_YEAR + ); + }); + + test('given non-numeric year when constructed then throws error', () => { + // When/Then + expect(() => new HouseholdBuilder(COUNTRIES.US, YEARS.INVALID_TEXT)).toThrow( + ERROR_MESSAGES.INVALID_YEAR + ); + }); + }); + + describe('build method', () => { + test('given household with data when build then returns deep clone', () => { + // Given + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + const household1 = builder.build(); + + // When + household1.householdData.people[PERSON_NAMES.ADULT_1].age[YEARS.CURRENT] = 99; + const household2 = builder.build(); + + // Then + expect(household2.householdData.people[PERSON_NAMES.ADULT_1].age[YEARS.CURRENT]).toBe( + PERSON_AGES.ADULT_DEFAULT + ); + }); + + test('given multiple builds when called then each returns independent copy', () => { + // Given + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + + // When + const household1 = builder.build(); + const household2 = builder.build(); + + // Then + expect(household1).not.toBe(household2); + expect(household1).toEqual(household2); + }); + }); + + describe('addAdult method', () => { + test('given adult data when addAdult then adds person to household', () => { + // When + const personKey = builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + const household = builder.build(); + + // Then + expect(personKey).toBe(PERSON_NAMES.ADULT_1); + verifyPersonExists(household, PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + }); + + test('given adult with variables when addAdult then includes variables', () => { + // Given + const variables = createMockVariables( + VARIABLE_VALUES.INCOME_DEFAULT, + VARIABLE_VALUES.STATE_CA + ); + + // When + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT, variables); + const household = builder.build(); + + // Then + const person = household.householdData.people[PERSON_NAMES.ADULT_1]; + verifyVariableValue(person, VARIABLE_NAMES.EMPLOYMENT_INCOME, VARIABLE_VALUES.INCOME_DEFAULT); + verifyVariableValue(person, VARIABLE_NAMES.STATE_CODE, VARIABLE_VALUES.STATE_CA); + }); + + test('given US adult when addAdult then adds to tax unit and household', () => { + // When + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + const household = builder.build(); + + // Then + verifyPersonInGroup( + household, + PERSON_NAMES.ADULT_1, + ENTITY_NAMES.TAX_UNITS, + GROUP_KEYS.DEFAULT_TAX_UNIT + ); + verifyPersonInGroup( + household, + PERSON_NAMES.ADULT_1, + ENTITY_NAMES.HOUSEHOLDS, + GROUP_KEYS.DEFAULT_HOUSEHOLD + ); + }); + + test('given UK adult when addAdult then adds to benefit unit and household', () => { + // Given + builder = new HouseholdBuilder(COUNTRIES.UK as any, YEARS.CURRENT); + + // When + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + const household = builder.build(); + + // Then + verifyPersonInGroup( + household, + PERSON_NAMES.ADULT_1, + ENTITY_NAMES.BEN_UNITS, + GROUP_KEYS.DEFAULT_BEN_UNIT + ); + verifyPersonInGroup( + household, + PERSON_NAMES.ADULT_1, + ENTITY_NAMES.HOUSEHOLDS, + GROUP_KEYS.DEFAULT_HOUSEHOLD + ); + }); + + test('given multiple adults when addAdult then adds all to same default groups', () => { + // When + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + builder.addAdult(PERSON_NAMES.ADULT_2, PERSON_AGES.ADULT_YOUNG); + const household = builder.build(); + + // Then + expect( + countGroupMembers(household, ENTITY_NAMES.TAX_UNITS, GROUP_KEYS.DEFAULT_TAX_UNIT) + ).toBe(2); + expect( + countGroupMembers(household, ENTITY_NAMES.HOUSEHOLDS, GROUP_KEYS.DEFAULT_HOUSEHOLD) + ).toBe(2); + }); + + test('given year-keyed variables when addAdult then preserves structure', () => { + // Given + const variables = { + [VARIABLE_NAMES.EMPLOYMENT_INCOME]: createYearKeyedValue( + VARIABLE_VALUES.INCOME_DEFAULT, + YEARS.PAST + ), + }; + + // When + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT, variables); + const household = builder.build(); + + // Then + const person = household.householdData.people[PERSON_NAMES.ADULT_1]; + expect(person[VARIABLE_NAMES.EMPLOYMENT_INCOME][YEARS.PAST]).toBe( + VARIABLE_VALUES.INCOME_DEFAULT + ); + }); + }); + + describe('addChild method', () => { + test('given child data when addChild then adds person to household', () => { + // When + const childKey = builder.addChild(PERSON_NAMES.CHILD_1, PERSON_AGES.CHILD_DEFAULT, []); + const household = builder.build(); + + // Then + expect(childKey).toBe(PERSON_NAMES.CHILD_1); + verifyPersonExists(household, PERSON_NAMES.CHILD_1, PERSON_AGES.CHILD_DEFAULT); + }); + + test('given US child when addChild then sets tax unit dependent flag', () => { + // When + builder.addChild(PERSON_NAMES.CHILD_1, PERSON_AGES.CHILD_DEFAULT, []); + const household = builder.build(); + + // Then + const child = household.householdData.people[PERSON_NAMES.CHILD_1]; + verifyVariableValue(child, VARIABLE_NAMES.IS_TAX_UNIT_DEPENDENT, true); + }); + + test('given UK child when addChild then does not set tax unit dependent flag', () => { + // Given + builder = new HouseholdBuilder(COUNTRIES.UK as any, YEARS.CURRENT); + + // When + builder.addChild(PERSON_NAMES.CHILD_1, PERSON_AGES.CHILD_DEFAULT, []); + const household = builder.build(); + + // Then + const child = household.householdData.people[PERSON_NAMES.CHILD_1]; + expect(child[VARIABLE_NAMES.IS_TAX_UNIT_DEPENDENT]).toBeUndefined(); + }); + + test('given child with parents when addChild then adds to household groups', () => { + // Given + const parent1 = builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + const parent2 = builder.addAdult(PERSON_NAMES.ADULT_2, PERSON_AGES.ADULT_DEFAULT); + + // When + builder.addChild(PERSON_NAMES.CHILD_1, PERSON_AGES.CHILD_DEFAULT, [parent1, parent2]); + const household = builder.build(); + + // Then + verifyPersonInGroup( + household, + PERSON_NAMES.CHILD_1, + ENTITY_NAMES.HOUSEHOLDS, + GROUP_KEYS.DEFAULT_HOUSEHOLD + ); + expect( + countGroupMembers(household, ENTITY_NAMES.HOUSEHOLDS, GROUP_KEYS.DEFAULT_HOUSEHOLD) + ).toBe(3); + }); + + test('given child with variables when addChild then includes variables', () => { + // Given + const variables = { [VARIABLE_NAMES.CUSTOM_VAR]: VARIABLE_VALUES.STRING_VALUE }; + + // When + builder.addChild(PERSON_NAMES.CHILD_1, PERSON_AGES.CHILD_DEFAULT, [], variables); + const household = builder.build(); + + // Then + const child = household.householdData.people[PERSON_NAMES.CHILD_1]; + verifyVariableValue(child, VARIABLE_NAMES.CUSTOM_VAR, VARIABLE_VALUES.STRING_VALUE); + }); + }); + + describe('addChildren method', () => { + test('given count of 1 when addChildren then adds single child with base name', () => { + // When + const childKeys = builder.addChildren( + PERSON_NAMES.CHILD_BASE, + 1, + PERSON_AGES.CHILD_DEFAULT, + [] + ); + const household = builder.build(); + + // Then + expect(childKeys).toHaveLength(1); + expect(childKeys[0]).toBe(PERSON_NAMES.CHILD_BASE); + verifyPersonExists(household, PERSON_NAMES.CHILD_BASE, PERSON_AGES.CHILD_DEFAULT); + }); + + test('given count of 3 when addChildren then adds numbered children', () => { + // When + const childKeys = builder.addChildren( + PERSON_NAMES.CHILD_BASE, + 3, + PERSON_AGES.CHILD_DEFAULT, + [] + ); + const household = builder.build(); + + // Then + expect(childKeys).toHaveLength(3); + expect(childKeys[0]).toBe(`${PERSON_NAMES.CHILD_BASE} 1`); + expect(childKeys[1]).toBe(`${PERSON_NAMES.CHILD_BASE} 2`); + expect(childKeys[2]).toBe(`${PERSON_NAMES.CHILD_BASE} 3`); + expect(countPeople(household)).toBe(3); + }); + + test('given variables when addChildren then applies to all children', () => { + // Given + const variables = { [VARIABLE_NAMES.CUSTOM_VAR]: VARIABLE_VALUES.NUMBER_VALUE }; + + // When + builder.addChildren(PERSON_NAMES.CHILD_BASE, 2, PERSON_AGES.CHILD_TEEN, [], variables); + const household = builder.build(); + + // Then + const child1 = household.householdData.people[`${PERSON_NAMES.CHILD_BASE} 1`]; + const child2 = household.householdData.people[`${PERSON_NAMES.CHILD_BASE} 2`]; + verifyVariableValue(child1, VARIABLE_NAMES.CUSTOM_VAR, VARIABLE_VALUES.NUMBER_VALUE); + verifyVariableValue(child2, VARIABLE_NAMES.CUSTOM_VAR, VARIABLE_VALUES.NUMBER_VALUE); + }); + }); + + describe('removePerson method', () => { + test('given person exists when removePerson then removes from household', () => { + // Given + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + builder.addAdult(PERSON_NAMES.ADULT_2, PERSON_AGES.ADULT_DEFAULT); + + // When + builder.removePerson(PERSON_NAMES.ADULT_1); + const household = builder.build(); + + // Then + expect(household.householdData.people[PERSON_NAMES.ADULT_1]).toBeUndefined(); + expect(household.householdData.people[PERSON_NAMES.ADULT_2]).toBeDefined(); + }); + + test('given person in groups when removePerson then removes from all groups', () => { + // Given + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + builder.addAdult(PERSON_NAMES.ADULT_2, PERSON_AGES.ADULT_DEFAULT); + + // When + builder.removePerson(PERSON_NAMES.ADULT_1); + const household = builder.build(); + + // Then + verifyPersonNotInAnyGroup(household, PERSON_NAMES.ADULT_1); + expect( + countGroupMembers(household, ENTITY_NAMES.TAX_UNITS, GROUP_KEYS.DEFAULT_TAX_UNIT) + ).toBe(1); + expect( + countGroupMembers(household, ENTITY_NAMES.HOUSEHOLDS, GROUP_KEYS.DEFAULT_HOUSEHOLD) + ).toBe(1); + }); + + test('given person not exists when removePerson then does nothing', () => { + // Given + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + + // When + builder.removePerson('non-existent'); + const household = builder.build(); + + // Then + expect(countPeople(household)).toBe(1); + }); + + test('given person removed when builder continues then can add new person', () => { + // Given + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + builder.removePerson(PERSON_NAMES.ADULT_1); + + // When + builder.addAdult(PERSON_NAMES.ADULT_2, PERSON_AGES.ADULT_DEFAULT); + const household = builder.build(); + + // Then + expect(countPeople(household)).toBe(1); + verifyPersonExists(household, PERSON_NAMES.ADULT_2, PERSON_AGES.ADULT_DEFAULT); + }); + }); + + describe('setPersonVariable method', () => { + test('given person exists when setPersonVariable then sets variable', () => { + // Given + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + + // When + builder.setPersonVariable( + PERSON_NAMES.ADULT_1, + VARIABLE_NAMES.EMPLOYMENT_INCOME, + VARIABLE_VALUES.INCOME_HIGH + ); + const household = builder.build(); + + // Then + const person = household.householdData.people[PERSON_NAMES.ADULT_1]; + verifyVariableValue(person, VARIABLE_NAMES.EMPLOYMENT_INCOME, VARIABLE_VALUES.INCOME_HIGH); + }); + + test('given person not exists when setPersonVariable then throws error', () => { + // When/Then + expect(() => + builder.setPersonVariable( + 'non-existent', + VARIABLE_NAMES.EMPLOYMENT_INCOME, + VARIABLE_VALUES.INCOME_DEFAULT + ) + ).toThrow(ERROR_MESSAGES.PERSON_NOT_FOUND('non-existent')); + }); + + test('given year-keyed value when setPersonVariable then preserves structure', () => { + // Given + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + const yearKeyedValue = createYearKeyedValue(VARIABLE_VALUES.INCOME_HIGH, YEARS.FUTURE); + + // When + builder.setPersonVariable( + PERSON_NAMES.ADULT_1, + VARIABLE_NAMES.EMPLOYMENT_INCOME, + yearKeyedValue + ); + const household = builder.build(); + + // Then + const person = household.householdData.people[PERSON_NAMES.ADULT_1]; + expect(person[VARIABLE_NAMES.EMPLOYMENT_INCOME][YEARS.FUTURE]).toBe( + VARIABLE_VALUES.INCOME_HIGH + ); + }); + + test('given existing variable when setPersonVariable then overwrites', () => { + // Given + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT, { + [VARIABLE_NAMES.EMPLOYMENT_INCOME]: VARIABLE_VALUES.INCOME_LOW, + }); + + // When + builder.setPersonVariable( + PERSON_NAMES.ADULT_1, + VARIABLE_NAMES.EMPLOYMENT_INCOME, + VARIABLE_VALUES.INCOME_HIGH + ); + const household = builder.build(); + + // Then + const person = household.householdData.people[PERSON_NAMES.ADULT_1]; + verifyVariableValue(person, VARIABLE_NAMES.EMPLOYMENT_INCOME, VARIABLE_VALUES.INCOME_HIGH); + }); + }); + + describe('setGroupVariable method', () => { + test('given group exists when setGroupVariable then sets variable', () => { + // Given + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + + // When + builder.setGroupVariable( + ENTITY_NAMES.HOUSEHOLDS, + GROUP_KEYS.DEFAULT_HOUSEHOLD, + VARIABLE_NAMES.STATE_CODE, + VARIABLE_VALUES.STATE_NY + ); + const household = builder.build(); + + // Then + const group = household.householdData.households![GROUP_KEYS.DEFAULT_HOUSEHOLD]; + verifyVariableValue(group, VARIABLE_NAMES.STATE_CODE, VARIABLE_VALUES.STATE_NY); + }); + + test('given group not exists when setGroupVariable then throws error', () => { + // When/Then + expect(() => + builder.setGroupVariable( + ENTITY_NAMES.HOUSEHOLDS, + 'non-existent', + VARIABLE_NAMES.STATE_CODE, + VARIABLE_VALUES.STATE_CA + ) + ).toThrow(ERROR_MESSAGES.GROUP_NOT_FOUND('non-existent', ENTITY_NAMES.HOUSEHOLDS)); + }); + + test('given entity not exists when setGroupVariable then throws error', () => { + // When/Then + expect(() => + builder.setGroupVariable( + 'non-existent-entity', + GROUP_KEYS.DEFAULT_HOUSEHOLD, + VARIABLE_NAMES.STATE_CODE, + VARIABLE_VALUES.STATE_CA + ) + ).toThrow(); + }); + + test('given year-keyed value when setGroupVariable then preserves structure', () => { + // Given + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + const yearKeyedValue = createYearKeyedValue(VARIABLE_VALUES.STATE_NY, YEARS.PAST); + + // When + builder.setGroupVariable( + ENTITY_NAMES.HOUSEHOLDS, + GROUP_KEYS.DEFAULT_HOUSEHOLD, + VARIABLE_NAMES.STATE_CODE, + yearKeyedValue + ); + const household = builder.build(); + + // Then + const group = household.householdData.households![GROUP_KEYS.DEFAULT_HOUSEHOLD]; + expect(group[VARIABLE_NAMES.STATE_CODE][YEARS.PAST]).toBe(VARIABLE_VALUES.STATE_NY); + }); + }); + + describe('assignToGroupEntity method', () => { + test('given existing group when assignToGroupEntity then adds person to group', () => { + // Given + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + + // When + builder.assignToGroupEntity( + PERSON_NAMES.ADULT_1, + ENTITY_NAMES.FAMILIES, + GROUP_KEYS.DEFAULT_FAMILY + ); + const household = builder.build(); + + // Then + verifyPersonInGroup( + household, + PERSON_NAMES.ADULT_1, + ENTITY_NAMES.FAMILIES, + GROUP_KEYS.DEFAULT_FAMILY + ); + }); + + test('given group not exists when assignToGroupEntity then creates group', () => { + // Given + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + + // When + builder.assignToGroupEntity( + PERSON_NAMES.ADULT_1, + ENTITY_NAMES.FAMILIES, + GROUP_KEYS.CUSTOM_GROUP + ); + const household = builder.build(); + + // Then + const families = household.householdData.families!; + expect(families[GROUP_KEYS.CUSTOM_GROUP]).toBeDefined(); + expect(families[GROUP_KEYS.CUSTOM_GROUP].members).toContain(PERSON_NAMES.ADULT_1); + }); + + test('given person already in group when assignToGroupEntity then does not duplicate', () => { + // Given + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + + // When + builder.assignToGroupEntity( + PERSON_NAMES.ADULT_1, + ENTITY_NAMES.HOUSEHOLDS, + GROUP_KEYS.DEFAULT_HOUSEHOLD + ); + builder.assignToGroupEntity( + PERSON_NAMES.ADULT_1, + ENTITY_NAMES.HOUSEHOLDS, + GROUP_KEYS.DEFAULT_HOUSEHOLD + ); + const household = builder.build(); + + // Then + const members = household.householdData.households![GROUP_KEYS.DEFAULT_HOUSEHOLD].members; + expect(members.filter((m: string) => m === PERSON_NAMES.ADULT_1)).toHaveLength(1); + }); + + test('given entity not exists when assignToGroupEntity then creates entity', () => { + // Given + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + delete builder.getHousehold().householdData.families; + + // When + builder.assignToGroupEntity( + PERSON_NAMES.ADULT_1, + ENTITY_NAMES.FAMILIES, + GROUP_KEYS.DEFAULT_FAMILY + ); + const household = builder.build(); + + // Then + expect(household.householdData.families).toBeDefined(); + verifyPersonInGroup( + household, + PERSON_NAMES.ADULT_1, + ENTITY_NAMES.FAMILIES, + GROUP_KEYS.DEFAULT_FAMILY + ); + }); + }); + + describe('setMaritalStatus method', () => { + test('given US household when setMaritalStatus then creates marital unit', () => { + // Given + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + builder.addAdult(PERSON_NAMES.ADULT_2, PERSON_AGES.ADULT_DEFAULT); + + // When + builder.setMaritalStatus(PERSON_NAMES.ADULT_1, PERSON_NAMES.ADULT_2); + const household = builder.build(); + + // Then + const maritalUnits = household.householdData.maritalUnits!; + expect(maritalUnits[GROUP_KEYS.DEFAULT_MARITAL_UNIT]).toBeDefined(); + expect(maritalUnits[GROUP_KEYS.DEFAULT_MARITAL_UNIT].members).toContain(PERSON_NAMES.ADULT_1); + expect(maritalUnits[GROUP_KEYS.DEFAULT_MARITAL_UNIT].members).toContain(PERSON_NAMES.ADULT_2); + }); + + test('given UK household when setMaritalStatus then does not create marital unit', () => { + // Given + builder = new HouseholdBuilder(COUNTRIES.UK as any, YEARS.CURRENT); + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + builder.addAdult(PERSON_NAMES.ADULT_2, PERSON_AGES.ADULT_DEFAULT); + + // When + builder.setMaritalStatus(PERSON_NAMES.ADULT_1, PERSON_NAMES.ADULT_2); + const household = builder.build(); + + // Then + expect(household.householdData.maritalUnits).toBeUndefined(); + }); + + test('given Canada household when setMaritalStatus then does not create marital unit', () => { + // Given + builder = new HouseholdBuilder(COUNTRIES.CA as any, YEARS.CURRENT); + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + builder.addAdult(PERSON_NAMES.ADULT_2, PERSON_AGES.ADULT_DEFAULT); + + // When + builder.setMaritalStatus(PERSON_NAMES.ADULT_1, PERSON_NAMES.ADULT_2); + const household = builder.build(); + + // Then + expect(household.householdData.maritalUnits).toBeUndefined(); + }); + }); + + describe('setCurrentYear method', () => { + test('given valid year when setCurrentYear then updates year for new data', () => { + // Given + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + + // When + builder.setCurrentYear(YEARS.FUTURE); + builder.addAdult(PERSON_NAMES.ADULT_2, PERSON_AGES.ADULT_DEFAULT); + const household = builder.build(); + + // Then + const person1 = household.householdData.people[PERSON_NAMES.ADULT_1]; + const person2 = household.householdData.people[PERSON_NAMES.ADULT_2]; + expect(person1.age[YEARS.CURRENT]).toBe(PERSON_AGES.ADULT_DEFAULT); + expect(person2.age[YEARS.FUTURE]).toBe(PERSON_AGES.ADULT_DEFAULT); + }); + + test('given invalid year when setCurrentYear then throws error', () => { + // When/Then + expect(() => builder.setCurrentYear(YEARS.INVALID)).toThrow(ERROR_MESSAGES.YEAR_FORMAT); + }); + + test('given non-numeric year when setCurrentYear then throws error', () => { + // When/Then + expect(() => builder.setCurrentYear(YEARS.INVALID_TEXT)).toThrow(ERROR_MESSAGES.YEAR_FORMAT); + }); + + test('given year changed when setting variables then uses new year', () => { + // Given + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + builder.setCurrentYear(YEARS.PAST); + + // When + builder.setPersonVariable( + PERSON_NAMES.ADULT_1, + VARIABLE_NAMES.EMPLOYMENT_INCOME, + VARIABLE_VALUES.INCOME_DEFAULT + ); + const household = builder.build(); + + // Then + const person = household.householdData.people[PERSON_NAMES.ADULT_1]; + expect(person[VARIABLE_NAMES.EMPLOYMENT_INCOME][YEARS.PAST]).toBe( + VARIABLE_VALUES.INCOME_DEFAULT + ); + expect(person[VARIABLE_NAMES.EMPLOYMENT_INCOME][YEARS.CURRENT]).toBeUndefined(); + }); + }); + + describe('loadHousehold method', () => { + test('given existing household when loadHousehold then loads for modification', () => { + // Given + const existingHousehold = createEmptyHousehold(COUNTRIES.US); + existingHousehold.householdData.people[PERSON_NAMES.ADULT_1] = { + age: { [YEARS.CURRENT]: PERSON_AGES.ADULT_DEFAULT }, + }; + + // When + builder.loadHousehold(existingHousehold); + builder.addAdult(PERSON_NAMES.ADULT_2, PERSON_AGES.ADULT_DEFAULT); + const household = builder.build(); + + // Then + expect(countPeople(household)).toBe(2); + verifyPersonExists(household, PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + verifyPersonExists(household, PERSON_NAMES.ADULT_2, PERSON_AGES.ADULT_DEFAULT); + }); + + test('given loaded household when modified then original unchanged', () => { + // Given + const existingHousehold = createEmptyHousehold(COUNTRIES.US); + existingHousehold.householdData.people[PERSON_NAMES.ADULT_1] = { + age: { [YEARS.CURRENT]: PERSON_AGES.ADULT_DEFAULT }, + }; + + // When + builder.loadHousehold(existingHousehold); + builder.removePerson(PERSON_NAMES.ADULT_1); + builder.build(); + + // Then + expect(existingHousehold.householdData.people[PERSON_NAMES.ADULT_1]).toBeDefined(); + }); + }); + + describe('getHousehold method', () => { + test('given household with data when getHousehold then returns current state', () => { + // Given + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + + // When + const household = builder.getHousehold(); + + // Then + verifyPersonExists(household, PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + }); + + test('given getHousehold when modified then affects builder state', () => { + // Given + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + + // When + const household = builder.getHousehold(); + household.householdData.people[PERSON_NAMES.ADULT_1].age[YEARS.CURRENT] = 99; + const builtHousehold = builder.build(); + + // Then + expect(builtHousehold.householdData.people[PERSON_NAMES.ADULT_1].age[YEARS.CURRENT]).toBe(99); + }); + }); + + describe('fluent API', () => { + test('given chained operations when building then all apply correctly', () => { + // When + // Note: addAdult and addChild return person IDs, not the builder instance + const adult1Id = builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + const adult2Id = builder.addAdult(PERSON_NAMES.ADULT_2, PERSON_AGES.ADULT_DEFAULT); + const childId = builder.addChild(PERSON_NAMES.CHILD_1, PERSON_AGES.CHILD_DEFAULT, [ + adult1Id, + adult2Id, + ]); + + // These methods support chaining (return 'this') + const household = builder + .setMaritalStatus(adult1Id, adult2Id) + .setPersonVariable( + adult1Id, + VARIABLE_NAMES.EMPLOYMENT_INCOME, + VARIABLE_VALUES.INCOME_DEFAULT + ) + .setGroupVariable( + ENTITY_NAMES.HOUSEHOLDS, + GROUP_KEYS.DEFAULT_HOUSEHOLD, + VARIABLE_NAMES.STATE_CODE, + VARIABLE_VALUES.STATE_CA + ) + .build(); + + // Then + expect(countPeople(household)).toBe(3); + verifyPersonExists(household, adult1Id); + verifyPersonExists(household, adult2Id); + verifyPersonExists(household, childId); + + const person1 = household.householdData.people[adult1Id]; + verifyVariableValue( + person1, + VARIABLE_NAMES.EMPLOYMENT_INCOME, + VARIABLE_VALUES.INCOME_DEFAULT + ); + + const householdGroup = household.householdData.households![GROUP_KEYS.DEFAULT_HOUSEHOLD]; + verifyVariableValue(householdGroup, VARIABLE_NAMES.STATE_CODE, VARIABLE_VALUES.STATE_CA); + + const maritalUnit = household.householdData.maritalUnits![GROUP_KEYS.DEFAULT_MARITAL_UNIT]; + expect(maritalUnit.members).toHaveLength(2); + }); + }); + + describe('country-specific behaviors', () => { + test('given each supported country when constructed then creates appropriate structure', () => { + // Test each country + const countries = [COUNTRIES.US, COUNTRIES.UK, COUNTRIES.CA, COUNTRIES.NG, COUNTRIES.IL]; + + countries.forEach((country) => { + // Given/When + const countryBuilder = new HouseholdBuilder(country as any, YEARS.CURRENT); + const household = countryBuilder.build(); + + // Then + verifyHouseholdStructure(household, country); + + const expectedEntities = + EXPECTED_COUNTRY_ENTITIES[country as keyof typeof EXPECTED_COUNTRY_ENTITIES]; + if (expectedEntities) { + expectedEntities.forEach((entity) => { + if (entity !== 'people') { + expect(household.householdData[entity]).toBeDefined(); + } + }); + } + }); + }); + + test('given Nigeria household when adding people then uses basic structure', () => { + // Given + builder = new HouseholdBuilder(COUNTRIES.NG as any, YEARS.CURRENT); + + // When + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + const household = builder.build(); + + // Then + expect(household.householdData.people).toBeDefined(); + expect(household.householdData.households).toBeDefined(); + expect(household.householdData.families).toBeUndefined(); + expect(household.householdData.taxUnits).toBeUndefined(); + expect(household.householdData.benunits).toBeUndefined(); + }); + + test('given Israel household when adding people then uses basic structure', () => { + // Given + builder = new HouseholdBuilder(COUNTRIES.IL as any, YEARS.CURRENT); + + // When + builder.addAdult(PERSON_NAMES.ADULT_1, PERSON_AGES.ADULT_DEFAULT); + const household = builder.build(); + + // Then + expect(household.householdData.people).toBeDefined(); + expect(household.householdData.households).toBeDefined(); + expect(household.householdData.families).toBeUndefined(); + expect(household.householdData.taxUnits).toBeUndefined(); + expect(household.householdData.benunits).toBeUndefined(); + }); + }); +}); diff --git a/app/src/tests/unit/utils/HouseholdQueries.test.ts b/app/src/tests/unit/utils/HouseholdQueries.test.ts new file mode 100644 index 00000000..166a5c61 --- /dev/null +++ b/app/src/tests/unit/utils/HouseholdQueries.test.ts @@ -0,0 +1,716 @@ +import { describe, expect, test } from 'vitest'; +import { + createHouseholdWithPeople, + createPersonWithAge, + expectedGroupsHouseholds, + expectedGroupsMultiple, + mockEmptyHousehold, + mockHouseholdAgeTransition, + mockHouseholdMultipleGroups, + mockHouseholdMultiYear, + mockHouseholdTwoAdultsTwoChildren, + mockUKHousehold, + QUERY_AGES, + QUERY_ENTITY_NAMES, + QUERY_EXPECTED_COUNTS, + QUERY_GROUP_KEYS, + QUERY_PERSON_NAMES, + QUERY_VARIABLE_NAMES, + QUERY_VARIABLE_VALUES, + QUERY_YEARS, + verifyPeopleArray, + verifyPersonWithName, +} from '@/tests/fixtures/utils/householdQueriesMocks'; +import { + getAdultCount, + getAdults, + getAllPeople, + getChildCount, + getChildren, + getGroupMembers, + getGroups, + getGroupVariable, + getPersonCount, + getPersonVariable, + isEmpty, +} from '@/utils/HouseholdQueries'; + +describe('HouseholdQueries', () => { + describe('getAllPeople', () => { + test('given empty household when getting all people then returns empty array', () => { + // When + const result = getAllPeople(mockEmptyHousehold); + + // Then + expect(result).toEqual([]); + }); + + test('given household with people when getting all then returns all people with names', () => { + // When + const result = getAllPeople(mockHouseholdTwoAdultsTwoChildren); + + // Then + expect(result).toHaveLength(QUERY_EXPECTED_COUNTS.TWO_ADULTS_TWO_CHILDREN.TOTAL); + verifyPeopleArray(result, [ + QUERY_PERSON_NAMES.ADULT_1, + QUERY_PERSON_NAMES.ADULT_2, + QUERY_PERSON_NAMES.CHILD_1, + QUERY_PERSON_NAMES.CHILD_2, + ]); + + // Verify structure + const adult1 = result.find((p) => p.name === QUERY_PERSON_NAMES.ADULT_1); + expect(adult1).toBeDefined(); + expect(adult1!.age[QUERY_YEARS.CURRENT]).toBe(QUERY_AGES.ADULT_30); + expect(adult1![QUERY_VARIABLE_NAMES.EMPLOYMENT_INCOME][QUERY_YEARS.CURRENT]).toBe( + QUERY_VARIABLE_VALUES.INCOME_50K + ); + }); + + test('given single person household when getting all then returns one person', () => { + // Given + const household = createHouseholdWithPeople({ + [QUERY_PERSON_NAMES.ADULT_1]: createPersonWithAge(QUERY_AGES.ADULT_30), + }); + + // When + const result = getAllPeople(household); + + // Then + expect(result).toHaveLength(QUERY_EXPECTED_COUNTS.ONE_ADULT.TOTAL); + verifyPersonWithName(result[0], QUERY_PERSON_NAMES.ADULT_1, QUERY_AGES.ADULT_30); + }); + }); + + describe('getAdults', () => { + test('given household with adults and children when getting adults then returns only adults', () => { + // When + const result = getAdults(mockHouseholdTwoAdultsTwoChildren, QUERY_YEARS.CURRENT); + + // Then + expect(result).toHaveLength(QUERY_EXPECTED_COUNTS.TWO_ADULTS_TWO_CHILDREN.ADULTS); + verifyPeopleArray(result, [QUERY_PERSON_NAMES.ADULT_1, QUERY_PERSON_NAMES.ADULT_2]); + }); + + test('given person turning 18 when querying different years then returns correct adults', () => { + // When - Past year (age 17) + const pastResult = getAdults(mockHouseholdAgeTransition, QUERY_YEARS.PAST); + + // Then + expect(pastResult).toHaveLength(0); + + // When - Current year (age 18) + const currentResult = getAdults(mockHouseholdAgeTransition, QUERY_YEARS.CURRENT); + + // Then + expect(currentResult).toHaveLength(1); + verifyPersonWithName( + currentResult[0], + QUERY_PERSON_NAMES.TEEN, + QUERY_AGES.ADULT_EXACTLY_18, + QUERY_YEARS.CURRENT + ); + + // When - Future year (age 25) + const futureResult = getAdults(mockHouseholdAgeTransition, QUERY_YEARS.FUTURE); + + // Then + expect(futureResult).toHaveLength(1); + verifyPersonWithName( + futureResult[0], + QUERY_PERSON_NAMES.TEEN, + QUERY_AGES.ADULT_25, + QUERY_YEARS.FUTURE + ); + }); + + test('given empty household when getting adults then returns empty array', () => { + // When + const result = getAdults(mockEmptyHousehold, QUERY_YEARS.CURRENT); + + // Then + expect(result).toEqual([]); + }); + + test('given household with only children when getting adults then returns empty array', () => { + // Given + const household = createHouseholdWithPeople({ + [QUERY_PERSON_NAMES.CHILD_1]: createPersonWithAge(QUERY_AGES.CHILD_10), + [QUERY_PERSON_NAMES.CHILD_2]: createPersonWithAge(QUERY_AGES.CHILD_5), + }); + + // When + const result = getAdults(household, QUERY_YEARS.CURRENT); + + // Then + expect(result).toEqual([]); + }); + + test('given 18-year-old when getting adults then includes them', () => { + // Given + const household = createHouseholdWithPeople({ + [QUERY_PERSON_NAMES.TEEN]: createPersonWithAge(QUERY_AGES.ADULT_EXACTLY_18), + }); + + // When + const result = getAdults(household, QUERY_YEARS.CURRENT); + + // Then + expect(result).toHaveLength(1); + verifyPersonWithName(result[0], QUERY_PERSON_NAMES.TEEN, QUERY_AGES.ADULT_EXACTLY_18); + }); + + test('given non-existent year when getting adults then returns empty array', () => { + // When + const result = getAdults(mockHouseholdTwoAdultsTwoChildren, QUERY_YEARS.NON_EXISTENT); + + // Then + expect(result).toEqual([]); + }); + }); + + describe('getChildren', () => { + test('given household with adults and children when getting children then returns only children', () => { + // When + const result = getChildren(mockHouseholdTwoAdultsTwoChildren, QUERY_YEARS.CURRENT); + + // Then + expect(result).toHaveLength(QUERY_EXPECTED_COUNTS.TWO_ADULTS_TWO_CHILDREN.CHILDREN); + verifyPeopleArray(result, [QUERY_PERSON_NAMES.CHILD_1, QUERY_PERSON_NAMES.CHILD_2]); + }); + + test('given person turning 18 when querying different years then returns correct children', () => { + // When - Past year (age 17) + const pastResult = getChildren(mockHouseholdAgeTransition, QUERY_YEARS.PAST); + + // Then + expect(pastResult).toHaveLength(1); + verifyPersonWithName( + pastResult[0], + QUERY_PERSON_NAMES.TEEN, + QUERY_AGES.CHILD_ALMOST_18, + QUERY_YEARS.PAST + ); + + // When - Current year (age 18) + const currentResult = getChildren(mockHouseholdAgeTransition, QUERY_YEARS.CURRENT); + + // Then + expect(currentResult).toHaveLength(0); + + // When - Future year (age 25) + const futureResult = getChildren(mockHouseholdAgeTransition, QUERY_YEARS.FUTURE); + + // Then + expect(futureResult).toHaveLength(0); + }); + + test('given empty household when getting children then returns empty array', () => { + // When + const result = getChildren(mockEmptyHousehold, QUERY_YEARS.CURRENT); + + // Then + expect(result).toEqual([]); + }); + + test('given household with only adults when getting children then returns empty array', () => { + // Given + const household = createHouseholdWithPeople({ + [QUERY_PERSON_NAMES.ADULT_1]: createPersonWithAge(QUERY_AGES.ADULT_30), + [QUERY_PERSON_NAMES.ADULT_2]: createPersonWithAge(QUERY_AGES.ADULT_25), + }); + + // When + const result = getChildren(household, QUERY_YEARS.CURRENT); + + // Then + expect(result).toEqual([]); + }); + + test('given 17-year-old when getting children then includes them', () => { + // Given + const household = createHouseholdWithPeople({ + [QUERY_PERSON_NAMES.TEEN]: createPersonWithAge(QUERY_AGES.CHILD_ALMOST_18), + }); + + // When + const result = getChildren(household, QUERY_YEARS.CURRENT); + + // Then + expect(result).toHaveLength(1); + verifyPersonWithName(result[0], QUERY_PERSON_NAMES.TEEN, QUERY_AGES.CHILD_ALMOST_18); + }); + + test('given non-existent year when getting children then returns empty array', () => { + // When + const result = getChildren(mockHouseholdTwoAdultsTwoChildren, QUERY_YEARS.NON_EXISTENT); + + // Then + expect(result).toEqual([]); + }); + }); + + describe('getPersonVariable', () => { + test('given existing person and variable when getting variable then returns value', () => { + // When + const result = getPersonVariable( + mockHouseholdTwoAdultsTwoChildren, + QUERY_PERSON_NAMES.ADULT_1, + QUERY_VARIABLE_NAMES.EMPLOYMENT_INCOME, + QUERY_YEARS.CURRENT + ); + + // Then + expect(result).toBe(QUERY_VARIABLE_VALUES.INCOME_50K); + }); + + test('given multi-year variable when getting different years then returns correct values', () => { + // When - Past year + const pastResult = getPersonVariable( + mockHouseholdMultiYear, + QUERY_PERSON_NAMES.ADULT_1, + QUERY_VARIABLE_NAMES.MULTI_YEAR, + QUERY_YEARS.PAST + ); + + // Then + expect(pastResult).toBe(QUERY_VARIABLE_VALUES.NUMBER_VALUE); + + // When - Current year + const currentResult = getPersonVariable( + mockHouseholdMultiYear, + QUERY_PERSON_NAMES.ADULT_1, + QUERY_VARIABLE_NAMES.MULTI_YEAR, + QUERY_YEARS.CURRENT + ); + + // Then + expect(currentResult).toBe(QUERY_VARIABLE_VALUES.STRING_VALUE); + + // When - Future year + const futureResult = getPersonVariable( + mockHouseholdMultiYear, + QUERY_PERSON_NAMES.ADULT_1, + QUERY_VARIABLE_NAMES.MULTI_YEAR, + QUERY_YEARS.FUTURE + ); + + // Then + expect(futureResult).toBe(QUERY_VARIABLE_VALUES.BOOLEAN_TRUE); + }); + + test('given non-existent person when getting variable then returns undefined', () => { + // When + const result = getPersonVariable( + mockHouseholdTwoAdultsTwoChildren, + 'non-existent-person', + QUERY_VARIABLE_NAMES.EMPLOYMENT_INCOME, + QUERY_YEARS.CURRENT + ); + + // Then + expect(result).toBeUndefined(); + }); + + test('given non-existent variable when getting variable then returns undefined', () => { + // When + const result = getPersonVariable( + mockHouseholdTwoAdultsTwoChildren, + QUERY_PERSON_NAMES.ADULT_1, + QUERY_VARIABLE_NAMES.NON_EXISTENT, + QUERY_YEARS.CURRENT + ); + + // Then + expect(result).toBeUndefined(); + }); + + test('given non-existent year when getting variable then returns undefined', () => { + // When + const result = getPersonVariable( + mockHouseholdTwoAdultsTwoChildren, + QUERY_PERSON_NAMES.ADULT_1, + QUERY_VARIABLE_NAMES.EMPLOYMENT_INCOME, + QUERY_YEARS.NON_EXISTENT + ); + + // Then + expect(result).toBeUndefined(); + }); + + test('given boolean variable when getting variable then returns boolean value', () => { + // When + const result = getPersonVariable( + mockHouseholdTwoAdultsTwoChildren, + QUERY_PERSON_NAMES.CHILD_1, + QUERY_VARIABLE_NAMES.IS_TAX_UNIT_DEPENDENT, + QUERY_YEARS.CURRENT + ); + + // Then + expect(result).toBe(QUERY_VARIABLE_VALUES.BOOLEAN_TRUE); + }); + }); + + describe('getGroupVariable', () => { + test('given existing group and variable when getting variable then returns value', () => { + // When + const result = getGroupVariable( + mockHouseholdTwoAdultsTwoChildren, + QUERY_ENTITY_NAMES.HOUSEHOLDS, + QUERY_GROUP_KEYS.DEFAULT_HOUSEHOLD, + QUERY_VARIABLE_NAMES.STATE_CODE, + QUERY_YEARS.CURRENT + ); + + // Then + expect(result).toBe(QUERY_VARIABLE_VALUES.STATE_CA); + }); + + test('given non-existent entity when getting variable then returns undefined', () => { + // When + const result = getGroupVariable( + mockHouseholdTwoAdultsTwoChildren, + QUERY_ENTITY_NAMES.NON_EXISTENT, + QUERY_GROUP_KEYS.DEFAULT_HOUSEHOLD, + QUERY_VARIABLE_NAMES.STATE_CODE, + QUERY_YEARS.CURRENT + ); + + // Then + expect(result).toBeUndefined(); + }); + + test('given non-existent group when getting variable then returns undefined', () => { + // When + const result = getGroupVariable( + mockHouseholdTwoAdultsTwoChildren, + QUERY_ENTITY_NAMES.HOUSEHOLDS, + QUERY_GROUP_KEYS.NON_EXISTENT, + QUERY_VARIABLE_NAMES.STATE_CODE, + QUERY_YEARS.CURRENT + ); + + // Then + expect(result).toBeUndefined(); + }); + + test('given non-existent variable when getting variable then returns undefined', () => { + // When + const result = getGroupVariable( + mockHouseholdTwoAdultsTwoChildren, + QUERY_ENTITY_NAMES.HOUSEHOLDS, + QUERY_GROUP_KEYS.DEFAULT_HOUSEHOLD, + QUERY_VARIABLE_NAMES.NON_EXISTENT, + QUERY_YEARS.CURRENT + ); + + // Then + expect(result).toBeUndefined(); + }); + + test('given non-existent year when getting variable then returns undefined', () => { + // When + const result = getGroupVariable( + mockHouseholdTwoAdultsTwoChildren, + QUERY_ENTITY_NAMES.HOUSEHOLDS, + QUERY_GROUP_KEYS.DEFAULT_HOUSEHOLD, + QUERY_VARIABLE_NAMES.STATE_CODE, + QUERY_YEARS.NON_EXISTENT + ); + + // Then + expect(result).toBeUndefined(); + }); + }); + + describe('getPersonCount', () => { + test('given empty household when counting people then returns zero', () => { + // When + const result = getPersonCount(mockEmptyHousehold); + + // Then + expect(result).toBe(QUERY_EXPECTED_COUNTS.EMPTY.TOTAL); + }); + + test('given household with people when counting then returns correct count', () => { + // When + const result = getPersonCount(mockHouseholdTwoAdultsTwoChildren); + + // Then + expect(result).toBe(QUERY_EXPECTED_COUNTS.TWO_ADULTS_TWO_CHILDREN.TOTAL); + }); + + test('given single person household when counting then returns one', () => { + // Given + const household = createHouseholdWithPeople({ + [QUERY_PERSON_NAMES.ADULT_1]: createPersonWithAge(QUERY_AGES.ADULT_30), + }); + + // When + const result = getPersonCount(household); + + // Then + expect(result).toBe(QUERY_EXPECTED_COUNTS.ONE_ADULT.TOTAL); + }); + + test('given multiple groups household when counting then returns total people', () => { + // When + const result = getPersonCount(mockHouseholdMultipleGroups); + + // Then + expect(result).toBe(3); + }); + }); + + describe('getAdultCount', () => { + test('given household with adults and children when counting adults then returns adult count', () => { + // When + const result = getAdultCount(mockHouseholdTwoAdultsTwoChildren, QUERY_YEARS.CURRENT); + + // Then + expect(result).toBe(QUERY_EXPECTED_COUNTS.TWO_ADULTS_TWO_CHILDREN.ADULTS); + }); + + test('given empty household when counting adults then returns zero', () => { + // When + const result = getAdultCount(mockEmptyHousehold, QUERY_YEARS.CURRENT); + + // Then + expect(result).toBe(QUERY_EXPECTED_COUNTS.EMPTY.ADULTS); + }); + + test('given household with only children when counting adults then returns zero', () => { + // Given + const household = createHouseholdWithPeople({ + [QUERY_PERSON_NAMES.CHILD_1]: createPersonWithAge(QUERY_AGES.CHILD_10), + [QUERY_PERSON_NAMES.CHILD_2]: createPersonWithAge(QUERY_AGES.CHILD_5), + }); + + // When + const result = getAdultCount(household, QUERY_YEARS.CURRENT); + + // Then + expect(result).toBe(0); + }); + + test('given person turning 18 when counting adults in different years then returns correct counts', () => { + // When/Then + expect(getAdultCount(mockHouseholdAgeTransition, QUERY_YEARS.PAST)).toBe(0); + expect(getAdultCount(mockHouseholdAgeTransition, QUERY_YEARS.CURRENT)).toBe(1); + expect(getAdultCount(mockHouseholdAgeTransition, QUERY_YEARS.FUTURE)).toBe(1); + }); + }); + + describe('getChildCount', () => { + test('given household with adults and children when counting children then returns child count', () => { + // When + const result = getChildCount(mockHouseholdTwoAdultsTwoChildren, QUERY_YEARS.CURRENT); + + // Then + expect(result).toBe(QUERY_EXPECTED_COUNTS.TWO_ADULTS_TWO_CHILDREN.CHILDREN); + }); + + test('given empty household when counting children then returns zero', () => { + // When + const result = getChildCount(mockEmptyHousehold, QUERY_YEARS.CURRENT); + + // Then + expect(result).toBe(QUERY_EXPECTED_COUNTS.EMPTY.CHILDREN); + }); + + test('given household with only adults when counting children then returns zero', () => { + // Given + const household = createHouseholdWithPeople({ + [QUERY_PERSON_NAMES.ADULT_1]: createPersonWithAge(QUERY_AGES.ADULT_30), + [QUERY_PERSON_NAMES.ADULT_2]: createPersonWithAge(QUERY_AGES.ADULT_25), + }); + + // When + const result = getChildCount(household, QUERY_YEARS.CURRENT); + + // Then + expect(result).toBe(0); + }); + + test('given person turning 18 when counting children in different years then returns correct counts', () => { + // When/Then + expect(getChildCount(mockHouseholdAgeTransition, QUERY_YEARS.PAST)).toBe(1); + expect(getChildCount(mockHouseholdAgeTransition, QUERY_YEARS.CURRENT)).toBe(0); + expect(getChildCount(mockHouseholdAgeTransition, QUERY_YEARS.FUTURE)).toBe(0); + }); + }); + + describe('isEmpty', () => { + test('given empty household when checking if empty then returns true', () => { + // When + const result = isEmpty(mockEmptyHousehold); + + // Then + expect(result).toBe(true); + }); + + test('given household with people when checking if empty then returns false', () => { + // When + const result = isEmpty(mockHouseholdTwoAdultsTwoChildren); + + // Then + expect(result).toBe(false); + }); + + test('given single person household when checking if empty then returns false', () => { + // Given + const household = createHouseholdWithPeople({ + [QUERY_PERSON_NAMES.ADULT_1]: createPersonWithAge(QUERY_AGES.ADULT_30), + }); + + // When + const result = isEmpty(household); + + // Then + expect(result).toBe(false); + }); + }); + + describe('getGroupMembers', () => { + test('given existing group when getting members then returns member array', () => { + // When + const result = getGroupMembers( + mockHouseholdTwoAdultsTwoChildren, + QUERY_ENTITY_NAMES.HOUSEHOLDS, + QUERY_GROUP_KEYS.DEFAULT_HOUSEHOLD + ); + + // Then + expect(result).toEqual([ + QUERY_PERSON_NAMES.ADULT_1, + QUERY_PERSON_NAMES.ADULT_2, + QUERY_PERSON_NAMES.CHILD_1, + QUERY_PERSON_NAMES.CHILD_2, + ]); + }); + + test('given non-existent entity when getting members then returns empty array', () => { + // When + const result = getGroupMembers( + mockHouseholdTwoAdultsTwoChildren, + QUERY_ENTITY_NAMES.NON_EXISTENT, + QUERY_GROUP_KEYS.DEFAULT_HOUSEHOLD + ); + + // Then + expect(result).toEqual([]); + }); + + test('given non-existent group when getting members then returns empty array', () => { + // When + const result = getGroupMembers( + mockHouseholdTwoAdultsTwoChildren, + QUERY_ENTITY_NAMES.HOUSEHOLDS, + QUERY_GROUP_KEYS.NON_EXISTENT + ); + + // Then + expect(result).toEqual([]); + }); + + test('given UK household when getting benefit unit members then returns correct members', () => { + // When + const result = getGroupMembers( + mockUKHousehold, + QUERY_ENTITY_NAMES.BEN_UNITS, + QUERY_GROUP_KEYS.DEFAULT_BEN_UNIT + ); + + // Then + expect(result).toEqual([QUERY_PERSON_NAMES.ADULT_1, QUERY_PERSON_NAMES.CHILD_1]); + }); + + test('given multiple groups when getting specific group members then returns only that group', () => { + // When - First household + const result1 = getGroupMembers( + mockHouseholdMultipleGroups, + QUERY_ENTITY_NAMES.HOUSEHOLDS, + QUERY_GROUP_KEYS.DEFAULT_HOUSEHOLD + ); + + // Then + expect(result1).toEqual([QUERY_PERSON_NAMES.ADULT_1, QUERY_PERSON_NAMES.ADULT_2]); + + // When - Second household + const result2 = getGroupMembers( + mockHouseholdMultipleGroups, + QUERY_ENTITY_NAMES.HOUSEHOLDS, + QUERY_GROUP_KEYS.SECOND_HOUSEHOLD + ); + + // Then + expect(result2).toEqual([QUERY_PERSON_NAMES.ADULT_3]); + }); + }); + + describe('getGroups', () => { + test('given entity with groups when getting groups then returns all groups', () => { + // When + const result = getGroups(mockHouseholdTwoAdultsTwoChildren, QUERY_ENTITY_NAMES.HOUSEHOLDS); + + // Then + expect(result).toEqual(expectedGroupsHouseholds); + }); + + test('given non-existent entity when getting groups then returns empty array', () => { + // When + const result = getGroups(mockHouseholdTwoAdultsTwoChildren, QUERY_ENTITY_NAMES.NON_EXISTENT); + + // Then + expect(result).toEqual([]); + }); + + test('given entity with no groups when getting groups then returns empty array', () => { + // When + const result = getGroups(mockEmptyHousehold, QUERY_ENTITY_NAMES.FAMILIES); + + // Then + expect(result).toEqual([]); + }); + + test('given multiple groups when getting groups then returns all groups', () => { + // When + const result = getGroups(mockHouseholdMultipleGroups, QUERY_ENTITY_NAMES.HOUSEHOLDS); + + // Then + expect(result).toEqual(expectedGroupsMultiple); + }); + + test('given UK household when getting benefit units then returns benefit unit groups', () => { + // When + const result = getGroups(mockUKHousehold, QUERY_ENTITY_NAMES.BEN_UNITS); + + // Then + expect(result).toEqual([ + { + key: QUERY_GROUP_KEYS.DEFAULT_BEN_UNIT, + members: [QUERY_PERSON_NAMES.ADULT_1, QUERY_PERSON_NAMES.CHILD_1], + }, + ]); + }); + + test('given tax units when getting groups then returns tax unit groups', () => { + // When + const result = getGroups(mockHouseholdTwoAdultsTwoChildren, QUERY_ENTITY_NAMES.TAX_UNITS); + + // Then + expect(result).toEqual([ + { + key: QUERY_GROUP_KEYS.DEFAULT_TAX_UNIT, + members: [ + QUERY_PERSON_NAMES.ADULT_1, + QUERY_PERSON_NAMES.ADULT_2, + QUERY_PERSON_NAMES.CHILD_1, + QUERY_PERSON_NAMES.CHILD_2, + ], + }, + ]); + }); + }); +}); diff --git a/app/src/tests/unit/utils/HouseholdValidation.test.ts b/app/src/tests/unit/utils/HouseholdValidation.test.ts new file mode 100644 index 00000000..aceacb27 --- /dev/null +++ b/app/src/tests/unit/utils/HouseholdValidation.test.ts @@ -0,0 +1,623 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { + mockBoolMetadata, + mockEmptyHousehold, + mockFloatMetadata, + mockHouseholdCountryMismatch, + mockHouseholdInvalidGroupStructure, + mockHouseholdMissingAge, + mockIntMetadata, + mockReduxStateNoMetadata, + mockReduxStateWithMetadata, + mockStringMetadata, + mockUKHouseholdEmptyBenUnit, + mockUSHouseholdInvalidMaritalUnit, + mockUSHouseholdNoTaxUnits, + mockUSHouseholdOrphanPerson, + mockValidUKHousehold, + mockValidUSHousehold, + VALIDATION_COUNTRIES, + VALIDATION_ENTITY_NAMES, + VALIDATION_ERROR_CODES, + VALIDATION_GROUP_KEYS, + VALIDATION_PERSON_NAMES, + VALIDATION_TEST_VALUES, + VALIDATION_VARIABLE_NAMES, + VALIDATION_WARNING_CODES, + VALIDATION_YEARS, + verifyHasErrors, + verifyNoErrors, + verifyValidationError, + verifyValidationWarning, + verifyWarningCount, +} from '@/tests/fixtures/utils/householdValidationMocks'; +import * as HouseholdQueries from '@/utils/HouseholdQueries'; +import { HouseholdValidation } from '@/utils/HouseholdValidation'; + +// Mock HouseholdQueries +vi.mock('@/utils/HouseholdQueries', () => ({ + getPersonCount: vi.fn(), +})); + +describe('HouseholdValidation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('validateForCountry', () => { + test('given valid US household when validating then returns valid result', () => { + // Given + vi.mocked(HouseholdQueries.getPersonCount).mockReturnValue(2); + + // When + const result = HouseholdValidation.validateForCountry( + mockValidUSHousehold, + VALIDATION_COUNTRIES.US + ); + + // Then + verifyNoErrors(result); + expect(result.warnings).toHaveLength(0); + }); + + test('given valid UK household when validating then returns valid result', () => { + // Given + vi.mocked(HouseholdQueries.getPersonCount).mockReturnValue(2); + + // When + const result = HouseholdValidation.validateForCountry( + mockValidUKHousehold, + VALIDATION_COUNTRIES.UK + ); + + // Then + verifyNoErrors(result); + }); + + test('given country mismatch when validating then returns error', () => { + // When + const result = HouseholdValidation.validateForCountry( + mockHouseholdCountryMismatch, + VALIDATION_COUNTRIES.UK + ); + + // Then + verifyHasErrors(result, 1); + verifyValidationError(result.errors, VALIDATION_ERROR_CODES.COUNTRY_MISMATCH, 'countryId'); + expect(result.errors[0].message).toContain(VALIDATION_COUNTRIES.US); + expect(result.errors[0].message).toContain(VALIDATION_COUNTRIES.UK); + }); + + test('given household with missing age when validating then returns warning', () => { + // When + const result = HouseholdValidation.validateForCountry( + mockHouseholdMissingAge, + VALIDATION_COUNTRIES.US + ); + + // Then + verifyNoErrors(result); + verifyWarningCount(result, 1); + verifyValidationWarning( + result.warnings, + VALIDATION_WARNING_CODES.MISSING_AGE, + `people.${VALIDATION_PERSON_NAMES.PERSON_NO_AGE}.age` + ); + }); + + test('given household with invalid group structure when validating then returns error', () => { + // When + const result = HouseholdValidation.validateForCountry( + mockHouseholdInvalidGroupStructure, + VALIDATION_COUNTRIES.US + ); + + // Then + verifyHasErrors(result, 1); + verifyValidationError( + result.errors, + VALIDATION_ERROR_CODES.INVALID_GROUP_STRUCTURE, + `households.${VALIDATION_GROUP_KEYS.DEFAULT_HOUSEHOLD}.members` + ); + }); + + test('given Canada household when validating then performs generic validation only', () => { + // When + const result = HouseholdValidation.validateForCountry( + mockValidUSHousehold, + VALIDATION_COUNTRIES.CA + ); + + // Then + // Should have country mismatch error but no US-specific warnings + verifyHasErrors(result, 1); + verifyValidationError(result.errors, VALIDATION_ERROR_CODES.COUNTRY_MISMATCH); + expect(result.warnings).toHaveLength(0); + }); + }); + + describe('validateGenericHousehold', () => { + test('given household with all required fields when validating then no errors or warnings', () => { + // Given + const errors: any[] = []; + const warnings: any[] = []; + + // When + HouseholdValidation.validateGenericHousehold( + mockValidUSHousehold, + errors, + warnings, + VALIDATION_YEARS.DEFAULT + ); + + // Then + expect(errors).toHaveLength(0); + expect(warnings).toHaveLength(0); + }); + + test('given person without age for current year when validating then adds warning', () => { + // Given + const errors: any[] = []; + const warnings: any[] = []; + + // When + HouseholdValidation.validateGenericHousehold( + mockHouseholdMissingAge, + errors, + warnings, + VALIDATION_YEARS.DEFAULT + ); + + // Then + expect(errors).toHaveLength(0); + expect(warnings).toHaveLength(1); + verifyValidationWarning(warnings, VALIDATION_WARNING_CODES.MISSING_AGE); + }); + + test('given group without members array when validating then adds error', () => { + // Given + const errors: any[] = []; + const warnings: any[] = []; + + // When + HouseholdValidation.validateGenericHousehold( + mockHouseholdInvalidGroupStructure, + errors, + warnings, + VALIDATION_YEARS.DEFAULT + ); + + // Then + expect(errors).toHaveLength(1); + verifyValidationError(errors, VALIDATION_ERROR_CODES.INVALID_GROUP_STRUCTURE); + }); + + test('given different year when validating then uses that year for age check', () => { + // Given + const errors: any[] = []; + const warnings: any[] = []; + + // When + HouseholdValidation.validateGenericHousehold( + mockValidUSHousehold, + errors, + warnings, + VALIDATION_YEARS.FUTURE + ); + + // Then + // Should have warnings for missing age in future year + expect(warnings.length).toBeGreaterThan(0); + const ageWarning = warnings.find((w) => w.code === VALIDATION_WARNING_CODES.MISSING_AGE); + expect(ageWarning?.message).toContain(VALIDATION_YEARS.FUTURE); + }); + + test('given no year parameter when validating then uses default year', () => { + // Given + const errors: any[] = []; + const warnings: any[] = []; + + // When + HouseholdValidation.validateGenericHousehold(mockValidUSHousehold, errors, warnings); + + // Then + expect(errors).toHaveLength(0); + // No warnings since the mock has age for default year + }); + }); + + describe('validateUSHousehold', () => { + test('given US household with all people in tax units when validating then no warnings', () => { + // Given + const errors: any[] = []; + const warnings: any[] = []; + vi.mocked(HouseholdQueries.getPersonCount).mockReturnValue(2); + + // When + HouseholdValidation.validateUSHousehold(mockValidUSHousehold, errors, warnings); + + // Then + expect(errors).toHaveLength(0); + expect(warnings).toHaveLength(0); + }); + + test('given US household with orphan person when validating then adds warning', () => { + // Given + const errors: any[] = []; + const warnings: any[] = []; + vi.mocked(HouseholdQueries.getPersonCount).mockReturnValue(3); + + // When + HouseholdValidation.validateUSHousehold(mockUSHouseholdOrphanPerson, errors, warnings); + + // Then + expect(errors).toHaveLength(0); + verifyWarningCount({ warnings, errors, isValid: true }, 1); + verifyValidationWarning( + warnings, + VALIDATION_WARNING_CODES.PERSON_NOT_IN_TAX_UNIT, + `people.${VALIDATION_PERSON_NAMES.PERSON_ORPHAN}` + ); + }); + + test('given US household with no tax units when validating then adds warning', () => { + // Given + const errors: any[] = []; + const warnings: any[] = []; + vi.mocked(HouseholdQueries.getPersonCount).mockReturnValue(1); + + // When + HouseholdValidation.validateUSHousehold(mockUSHouseholdNoTaxUnits, errors, warnings); + + // Then + expect(errors).toHaveLength(0); + verifyWarningCount({ warnings, errors, isValid: true }, 2); // No tax units + person not in tax unit + verifyValidationWarning(warnings, VALIDATION_WARNING_CODES.NO_TAX_UNITS, 'taxUnits'); + }); + + test('given US household with invalid marital unit when validating then adds error', () => { + // Given + const errors: any[] = []; + const warnings: any[] = []; + vi.mocked(HouseholdQueries.getPersonCount).mockReturnValue(1); + + // When + HouseholdValidation.validateUSHousehold(mockUSHouseholdInvalidMaritalUnit, errors, warnings); + + // Then + verifyValidationError( + errors, + VALIDATION_ERROR_CODES.INVALID_MARITAL_UNIT, + `maritalUnits.${VALIDATION_GROUP_KEYS.INVALID_MARITAL_UNIT}` + ); + }); + + test('given marital unit with exactly 2 members when validating then no error', () => { + // Given + const errors: any[] = []; + const warnings: any[] = []; + const household = { + ...mockValidUSHousehold, + householdData: { + ...mockValidUSHousehold.householdData, + maritalUnits: { + [VALIDATION_GROUP_KEYS.DEFAULT_MARITAL_UNIT]: { + members: [VALIDATION_PERSON_NAMES.ADULT_1, VALIDATION_PERSON_NAMES.ADULT_2], + }, + }, + }, + }; + vi.mocked(HouseholdQueries.getPersonCount).mockReturnValue(2); + + // When + HouseholdValidation.validateUSHousehold(household, errors, warnings); + + // Then + expect(errors).toHaveLength(0); + }); + + test('given household without tax units entity when validating then no warnings', () => { + // Given + const errors: any[] = []; + const warnings: any[] = []; + const household = { + ...mockValidUSHousehold, + householdData: { + people: mockValidUSHousehold.householdData.people, + households: mockValidUSHousehold.householdData.households, + // No taxUnits property + }, + }; + + // When + HouseholdValidation.validateUSHousehold(household, errors, warnings); + + // Then + expect(errors).toHaveLength(0); + expect(warnings).toHaveLength(0); + }); + }); + + describe('validateUKHousehold', () => { + test('given UK household with valid benefit units when validating then no errors', () => { + // Given + const errors: any[] = []; + + // When + HouseholdValidation.validateUKHousehold(mockValidUKHousehold, errors); + + // Then + expect(errors).toHaveLength(0); + }); + + test('given UK household with empty benefit unit when validating then adds error', () => { + // Given + const errors: any[] = []; + + // When + HouseholdValidation.validateUKHousehold(mockUKHouseholdEmptyBenUnit, errors); + + // Then + verifyValidationError( + errors, + VALIDATION_ERROR_CODES.EMPTY_BENUNIT, + `benunits.${VALIDATION_GROUP_KEYS.EMPTY_BEN_UNIT}` + ); + }); + + test('given UK household without benefit units entity when validating then no errors', () => { + // Given + const errors: any[] = []; + const household = { + ...mockValidUKHousehold, + householdData: { + people: mockValidUKHousehold.householdData.people, + households: mockValidUKHousehold.householdData.households, + // No benunits property + }, + }; + + // When + HouseholdValidation.validateUKHousehold(household, errors); + + // Then + expect(errors).toHaveLength(0); + }); + }); + + describe('canAddVariable', () => { + test('given matching entity type when checking can add variable then returns true', () => { + // When + const result = HouseholdValidation.canAddVariable( + VALIDATION_ENTITY_NAMES.PEOPLE, + mockFloatMetadata + ); + + // Then + expect(result).toBe(true); + }); + + test('given mismatched entity type when checking can add variable then returns false', () => { + // When + const result = HouseholdValidation.canAddVariable( + VALIDATION_ENTITY_NAMES.HOUSEHOLDS, + mockFloatMetadata // This is for 'people' entity + ); + + // Then + expect(result).toBe(false); + }); + + test('given no metadata when checking can add variable then returns true', () => { + // When + const result = HouseholdValidation.canAddVariable(VALIDATION_ENTITY_NAMES.FAMILIES); + + // Then + expect(result).toBe(true); + }); + + test('given custom entity type when checking can add variable then returns true', () => { + // When + const result = HouseholdValidation.canAddVariable('customEntity'); + + // Then + expect(result).toBe(true); + }); + }); + + describe('validateVariableValue', () => { + test('given valid float value when validating then returns valid', () => { + // When + const result = HouseholdValidation.validateVariableValue( + VALIDATION_TEST_VALUES.VALID_FLOAT, + mockFloatMetadata + ); + + // Then + verifyNoErrors(result); + }); + + test('given valid integer value when validating then returns valid', () => { + // When + const result = HouseholdValidation.validateVariableValue( + VALIDATION_TEST_VALUES.VALID_INT, + mockIntMetadata + ); + + // Then + verifyNoErrors(result); + }); + + test('given float for integer type when validating then returns error', () => { + // When + const result = HouseholdValidation.validateVariableValue( + VALIDATION_TEST_VALUES.INVALID_FLOAT_FOR_INT, + mockIntMetadata + ); + + // Then + verifyHasErrors(result, 1); + verifyValidationError(result.errors, VALIDATION_ERROR_CODES.NOT_INTEGER); + }); + + test('given string for number type when validating then returns error', () => { + // When + const result = HouseholdValidation.validateVariableValue( + VALIDATION_TEST_VALUES.INVALID_STRING_FOR_NUMBER, + mockFloatMetadata + ); + + // Then + verifyHasErrors(result, 1); + verifyValidationError(result.errors, VALIDATION_ERROR_CODES.INVALID_TYPE); + }); + + test('given valid boolean value when validating then returns valid', () => { + // When + const result = HouseholdValidation.validateVariableValue( + VALIDATION_TEST_VALUES.VALID_BOOL, + mockBoolMetadata + ); + + // Then + verifyNoErrors(result); + }); + + test('given number for boolean type when validating then returns error', () => { + // When + const result = HouseholdValidation.validateVariableValue( + VALIDATION_TEST_VALUES.INVALID_NUMBER_FOR_BOOL, + mockBoolMetadata + ); + + // Then + verifyHasErrors(result, 1); + verifyValidationError(result.errors, VALIDATION_ERROR_CODES.INVALID_TYPE); + }); + + test('given valid string value when validating then returns valid', () => { + // When + const result = HouseholdValidation.validateVariableValue( + VALIDATION_TEST_VALUES.VALID_STRING, + mockStringMetadata + ); + + // Then + verifyNoErrors(result); + }); + + test('given boolean for string type when validating then returns error', () => { + // When + const result = HouseholdValidation.validateVariableValue( + VALIDATION_TEST_VALUES.INVALID_BOOL_FOR_STRING, + mockStringMetadata + ); + + // Then + verifyHasErrors(result, 1); + verifyValidationError(result.errors, VALIDATION_ERROR_CODES.INVALID_TYPE); + }); + }); + + describe('isReadyForSimulation', () => { + test('given valid household when checking ready for simulation then returns valid', () => { + // Given + vi.mocked(HouseholdQueries.getPersonCount).mockReturnValue(2); + + // When + const result = HouseholdValidation.isReadyForSimulation(mockValidUSHousehold); + + // Then + verifyNoErrors(result); + }); + + test('given empty household when checking ready for simulation then returns error', () => { + // Given + vi.mocked(HouseholdQueries.getPersonCount).mockReturnValue(0); + + // When + const result = HouseholdValidation.isReadyForSimulation(mockEmptyHousehold); + + // Then + verifyHasErrors(result, 1); + verifyValidationError(result.errors, VALIDATION_ERROR_CODES.NO_PEOPLE, 'people'); + }); + + test('given household with structural errors when checking ready then includes those errors', () => { + // Given + vi.mocked(HouseholdQueries.getPersonCount).mockReturnValue(1); + + // When + const result = HouseholdValidation.isReadyForSimulation(mockHouseholdInvalidGroupStructure); + + // Then + verifyHasErrors(result, 1); + verifyValidationError(result.errors, VALIDATION_ERROR_CODES.INVALID_GROUP_STRUCTURE); + }); + + test('given household with warnings when checking ready then includes warnings', () => { + // Given + vi.mocked(HouseholdQueries.getPersonCount).mockReturnValue(2); + + // When + const result = HouseholdValidation.isReadyForSimulation(mockHouseholdMissingAge); + + // Then + verifyNoErrors(result); + verifyWarningCount(result, 1); + verifyValidationWarning(result.warnings, VALIDATION_WARNING_CODES.MISSING_AGE); + }); + }); + + describe('getVariableMetadata', () => { + test('given existing variable when getting metadata then returns metadata', () => { + // When + const result = HouseholdValidation.getVariableMetadata( + mockReduxStateWithMetadata as any, + VALIDATION_VARIABLE_NAMES.EMPLOYMENT_INCOME + ); + + // Then + expect(result).toBeDefined(); + expect(result?.name).toBe(VALIDATION_VARIABLE_NAMES.EMPLOYMENT_INCOME); + expect(result?.entity).toBe(VALIDATION_ENTITY_NAMES.PEOPLE); + expect(result?.valueType).toBe('float'); + }); + + test('given non-existent variable when getting metadata then returns undefined', () => { + // When + const result = HouseholdValidation.getVariableMetadata( + mockReduxStateWithMetadata as any, + 'non_existent_variable' + ); + + // Then + expect(result).toBeUndefined(); + }); + + test('given state without metadata when getting metadata then returns undefined', () => { + // When + const result = HouseholdValidation.getVariableMetadata( + mockReduxStateNoMetadata as any, + VALIDATION_VARIABLE_NAMES.EMPLOYMENT_INCOME + ); + + // Then + expect(result).toBeUndefined(); + }); + + test('given state with metadata when getting different variable then returns correct metadata', () => { + // When + const result = HouseholdValidation.getVariableMetadata( + mockReduxStateWithMetadata as any, + VALIDATION_VARIABLE_NAMES.STATE_CODE + ); + + // Then + expect(result).toBeDefined(); + expect(result?.name).toBe(VALIDATION_VARIABLE_NAMES.STATE_CODE); + expect(result?.entity).toBe(VALIDATION_ENTITY_NAMES.HOUSEHOLDS); + expect(result?.valueType).toBe('string'); + }); + }); +}); diff --git a/app/src/tests/unit/utils/PopulationOps.test.ts b/app/src/tests/unit/utils/PopulationOps.test.ts new file mode 100644 index 00000000..af76c7b0 --- /dev/null +++ b/app/src/tests/unit/utils/PopulationOps.test.ts @@ -0,0 +1,515 @@ +import { beforeEach, describe, expect, test } from 'vitest'; +import { + API_PAYLOAD_KEYS, + createGeographyPopRef, + createHouseholdPopRef, + createUserGeographyPop, + EXPECTED_LABELS, + expectedGeographyAPIPayload, + expectedGeographyCacheKey, + expectedGeographyLabel, + expectedHouseholdAPIPayload, + expectedHouseholdCacheKey, + expectedHouseholdLabel, + expectedUserGeographyLabel, + expectedUserGeographyNationalLabel, + expectedUserHouseholdDefaultLabel, + expectedUserHouseholdLabel, + mockGeographyPopRef1, + mockGeographyPopRef2, + mockGeographyPopRefEmpty, + mockHandlers, + mockHouseholdPopRef1, + mockHouseholdPopRef2, + mockHouseholdPopRefEmpty, + mockUserGeographyPop, + mockUserGeographyPopInvalid, + mockUserGeographyPopNational, + mockUserGeographyPopNoCountry, + mockUserHouseholdPop, + mockUserHouseholdPopInvalid, + mockUserHouseholdPopNoLabel, + mockUserHouseholdPopNoUser, + POPULATION_COUNTRIES, + POPULATION_IDS, + POPULATION_SCOPES, + resetMockHandlers, + setupMockHandlerReturns, + verifyAPIPayload, +} from '@/tests/fixtures/utils/populationOpsMocks'; +import { + matchPopulation, + matchUserPopulation, + PopulationOps, + UserPopulationOps, +} from '@/utils/PopulationOps'; + +describe('PopulationOps', () => { + describe('matchPopulation', () => { + beforeEach(() => { + resetMockHandlers(); + }); + + test('given household population when matching then calls household handler', () => { + // Given + setupMockHandlerReturns('household result', 'geography result'); + + // When + const result = matchPopulation(mockHouseholdPopRef1, mockHandlers); + + // Then + expect(mockHandlers.household).toHaveBeenCalledWith(mockHouseholdPopRef1); + expect(mockHandlers.household).toHaveBeenCalledTimes(1); + expect(mockHandlers.geography).not.toHaveBeenCalled(); + expect(result).toBe('household result'); + }); + + test('given geography population when matching then calls geography handler', () => { + // Given + setupMockHandlerReturns('household result', 'geography result'); + + // When + const result = matchPopulation(mockGeographyPopRef1, mockHandlers); + + // Then + expect(mockHandlers.geography).toHaveBeenCalledWith(mockGeographyPopRef1); + expect(mockHandlers.geography).toHaveBeenCalledTimes(1); + expect(mockHandlers.household).not.toHaveBeenCalled(); + expect(result).toBe('geography result'); + }); + }); + + describe('matchUserPopulation', () => { + beforeEach(() => { + resetMockHandlers(); + }); + + test('given household user population when matching then calls household handler', () => { + // Given + setupMockHandlerReturns('household user result', 'geography user result'); + + // When + const result = matchUserPopulation(mockUserHouseholdPop, mockHandlers); + + // Then + expect(mockHandlers.household).toHaveBeenCalledWith(mockUserHouseholdPop); + expect(mockHandlers.household).toHaveBeenCalledTimes(1); + expect(mockHandlers.geography).not.toHaveBeenCalled(); + expect(result).toBe('household user result'); + }); + + test('given geography user population when matching then calls geography handler', () => { + // Given + setupMockHandlerReturns('household user result', 'geography user result'); + + // When + const result = matchUserPopulation(mockUserGeographyPop, mockHandlers); + + // Then + expect(mockHandlers.geography).toHaveBeenCalledWith(mockUserGeographyPop); + expect(mockHandlers.geography).toHaveBeenCalledTimes(1); + expect(mockHandlers.household).not.toHaveBeenCalled(); + expect(result).toBe('geography user result'); + }); + }); + + describe('PopulationOps.getId', () => { + test('given household population when getting ID then returns household ID', () => { + // When + const result = PopulationOps.getId(mockHouseholdPopRef1); + + // Then + expect(result).toBe(POPULATION_IDS.HOUSEHOLD_1); + }); + + test('given geography population when getting ID then returns geography ID', () => { + // When + const result = PopulationOps.getId(mockGeographyPopRef1); + + // Then + expect(result).toBe(POPULATION_IDS.GEOGRAPHY_1); + }); + + test('given empty household ID when getting ID then returns empty string', () => { + // When + const result = PopulationOps.getId(mockHouseholdPopRefEmpty); + + // Then + expect(result).toBe(POPULATION_IDS.HOUSEHOLD_EMPTY); + }); + }); + + describe('PopulationOps.getLabel', () => { + test('given household population when getting label then returns formatted label', () => { + // When + const result = PopulationOps.getLabel(mockHouseholdPopRef1); + + // Then + expect(result).toBe(expectedHouseholdLabel); + }); + + test('given geography population when getting label then returns formatted label', () => { + // When + const result = PopulationOps.getLabel(mockGeographyPopRef1); + + // Then + expect(result).toBe(expectedGeographyLabel); + }); + }); + + describe('PopulationOps.getTypeLabel', () => { + test('given household population when getting type label then returns Household', () => { + // When + const result = PopulationOps.getTypeLabel(mockHouseholdPopRef1); + + // Then + expect(result).toBe(EXPECTED_LABELS.HOUSEHOLD_TYPE); + }); + + test('given geography population when getting type label then returns Geography', () => { + // When + const result = PopulationOps.getTypeLabel(mockGeographyPopRef1); + + // Then + expect(result).toBe(EXPECTED_LABELS.GEOGRAPHY_TYPE); + }); + }); + + describe('PopulationOps.toAPIPayload', () => { + test('given household population when converting to API payload then returns correct format', () => { + // When + const result = PopulationOps.toAPIPayload(mockHouseholdPopRef1); + + // Then + expect(result).toEqual(expectedHouseholdAPIPayload); + verifyAPIPayload( + result, + [API_PAYLOAD_KEYS.POPULATION_ID, API_PAYLOAD_KEYS.HOUSEHOLD_ID], + expectedHouseholdAPIPayload + ); + }); + + test('given geography population when converting to API payload then returns correct format', () => { + // When + const result = PopulationOps.toAPIPayload(mockGeographyPopRef1); + + // Then + expect(result).toEqual(expectedGeographyAPIPayload); + verifyAPIPayload( + result, + [API_PAYLOAD_KEYS.GEOGRAPHY_ID, API_PAYLOAD_KEYS.REGION], + expectedGeographyAPIPayload + ); + }); + }); + + describe('PopulationOps.getCacheKey', () => { + test('given household population when getting cache key then returns prefixed key', () => { + // When + const result = PopulationOps.getCacheKey(mockHouseholdPopRef1); + + // Then + expect(result).toBe(expectedHouseholdCacheKey); + }); + + test('given geography population when getting cache key then returns prefixed key', () => { + // When + const result = PopulationOps.getCacheKey(mockGeographyPopRef1); + + // Then + expect(result).toBe(expectedGeographyCacheKey); + }); + }); + + describe('PopulationOps.isValid', () => { + test('given household with valid ID when checking validity then returns true', () => { + // When + const result = PopulationOps.isValid(mockHouseholdPopRef1); + + // Then + expect(result).toBe(true); + }); + + test('given household with empty ID when checking validity then returns false', () => { + // When + const result = PopulationOps.isValid(mockHouseholdPopRefEmpty); + + // Then + expect(result).toBe(false); + }); + + test('given geography with valid ID when checking validity then returns true', () => { + // When + const result = PopulationOps.isValid(mockGeographyPopRef1); + + // Then + expect(result).toBe(true); + }); + + test('given geography with empty ID when checking validity then returns false', () => { + // When + const result = PopulationOps.isValid(mockGeographyPopRefEmpty); + + // Then + expect(result).toBe(false); + }); + }); + + describe('PopulationOps.fromUserPopulation', () => { + test('given household user population when converting then returns household ref', () => { + // When + const result = PopulationOps.fromUserPopulation(mockUserHouseholdPop); + + // Then + expect(result.type).toBe('household'); + expect((result as any).householdId).toBe(POPULATION_IDS.HOUSEHOLD_1); + }); + + test('given geography user population when converting then returns geography ref', () => { + // When + const result = PopulationOps.fromUserPopulation(mockUserGeographyPop); + + // Then + expect(result.type).toBe('geography'); + expect((result as any).geographyId).toBe(POPULATION_IDS.GEOGRAPHY_1); + }); + }); + + describe('PopulationOps.isEqual', () => { + test('given same household populations when comparing then returns true', () => { + // Given + const pop1 = createHouseholdPopRef(POPULATION_IDS.HOUSEHOLD_1); + const pop2 = createHouseholdPopRef(POPULATION_IDS.HOUSEHOLD_1); + + // When + const result = PopulationOps.isEqual(pop1, pop2); + + // Then + expect(result).toBe(true); + }); + + test('given different household populations when comparing then returns false', () => { + // When + const result = PopulationOps.isEqual(mockHouseholdPopRef1, mockHouseholdPopRef2); + + // Then + expect(result).toBe(false); + }); + + test('given same geography populations when comparing then returns true', () => { + // Given + const pop1 = createGeographyPopRef(POPULATION_IDS.GEOGRAPHY_1); + const pop2 = createGeographyPopRef(POPULATION_IDS.GEOGRAPHY_1); + + // When + const result = PopulationOps.isEqual(pop1, pop2); + + // Then + expect(result).toBe(true); + }); + + test('given different geography populations when comparing then returns false', () => { + // When + const result = PopulationOps.isEqual(mockGeographyPopRef1, mockGeographyPopRef2); + + // Then + expect(result).toBe(false); + }); + + test('given household and geography populations when comparing then returns false', () => { + // When + const result = PopulationOps.isEqual(mockHouseholdPopRef1, mockGeographyPopRef1); + + // Then + expect(result).toBe(false); + }); + }); + + describe('PopulationOps.household', () => { + test('given household ID when creating household ref then returns correct structure', () => { + // When + const result = PopulationOps.household(POPULATION_IDS.HOUSEHOLD_1); + + // Then + expect(result).toEqual({ + type: 'household', + householdId: POPULATION_IDS.HOUSEHOLD_1, + }); + }); + + test('given empty household ID when creating household ref then still creates ref', () => { + // When + const result = PopulationOps.household(''); + + // Then + expect(result).toEqual({ + type: 'household', + householdId: '', + }); + }); + }); + + describe('PopulationOps.geography', () => { + test('given geography ID when creating geography ref then returns correct structure', () => { + // When + const result = PopulationOps.geography(POPULATION_IDS.GEOGRAPHY_1); + + // Then + expect(result).toEqual({ + type: 'geography', + geographyId: POPULATION_IDS.GEOGRAPHY_1, + }); + }); + + test('given empty geography ID when creating geography ref then still creates ref', () => { + // When + const result = PopulationOps.geography(''); + + // Then + expect(result).toEqual({ + type: 'geography', + geographyId: '', + }); + }); + }); +}); + +describe('UserPopulationOps', () => { + describe('UserPopulationOps.getId', () => { + test('given household user population when getting ID then returns household ID', () => { + // When + const result = UserPopulationOps.getId(mockUserHouseholdPop); + + // Then + expect(result).toBe(POPULATION_IDS.HOUSEHOLD_1); + }); + + test('given geography user population when getting ID then returns geography ID', () => { + // When + const result = UserPopulationOps.getId(mockUserGeographyPop); + + // Then + expect(result).toBe(POPULATION_IDS.GEOGRAPHY_1); + }); + }); + + describe('UserPopulationOps.getLabel', () => { + test('given user population with label when getting label then returns custom label', () => { + // When + const result = UserPopulationOps.getLabel(mockUserHouseholdPop); + + // Then + expect(result).toBe(expectedUserHouseholdLabel); + }); + + test('given household user population without label when getting label then returns default', () => { + // When + const result = UserPopulationOps.getLabel(mockUserHouseholdPopNoLabel); + + // Then + expect(result).toBe(expectedUserHouseholdDefaultLabel); + }); + + test('given geography user population with label when getting label then returns custom label', () => { + // When + const result = UserPopulationOps.getLabel(mockUserGeographyPop); + + // Then + expect(result).toBe(expectedUserGeographyLabel); + }); + + test('given national geography without label when getting label then returns national format', () => { + // When + const result = UserPopulationOps.getLabel(mockUserGeographyPopNational); + + // Then + expect(result).toBe(expectedUserGeographyNationalLabel); + }); + + test('given subnational geography without label when getting label then returns regional format', () => { + // Given + const subNationalPop = createUserGeographyPop( + POPULATION_IDS.GEOGRAPHY_1, + POPULATION_COUNTRIES.US, + POPULATION_SCOPES.SUBNATIONAL as any, + POPULATION_IDS.USER_1 + ); + + // When + const result = UserPopulationOps.getLabel(subNationalPop); + + // Then + expect(result).toBe(`Regional: ${POPULATION_IDS.GEOGRAPHY_1}`); + }); + }); + + describe('UserPopulationOps.toPopulationRef', () => { + test('given household user population when converting then returns household ref', () => { + // When + const result = UserPopulationOps.toPopulationRef(mockUserHouseholdPop); + + // Then + expect(result.type).toBe('household'); + expect((result as any).householdId).toBe(POPULATION_IDS.HOUSEHOLD_1); + }); + + test('given geography user population when converting then returns geography ref', () => { + // When + const result = UserPopulationOps.toPopulationRef(mockUserGeographyPop); + + // Then + expect(result.type).toBe('geography'); + expect((result as any).geographyId).toBe(POPULATION_IDS.GEOGRAPHY_1); + }); + }); + + describe('UserPopulationOps.isValid', () => { + test('given valid household user population when checking validity then returns true', () => { + // When + const result = UserPopulationOps.isValid(mockUserHouseholdPop); + + // Then + expect(result).toBe(true); + }); + + test('given household with empty ID when checking validity then returns false', () => { + // When + const result = UserPopulationOps.isValid(mockUserHouseholdPopInvalid); + + // Then + expect(result).toBe(false); + }); + + test('given household with no user ID when checking validity then returns false', () => { + // When + const result = UserPopulationOps.isValid(mockUserHouseholdPopNoUser); + + // Then + expect(result).toBe(false); + }); + + test('given valid geography user population when checking validity then returns true', () => { + // When + const result = UserPopulationOps.isValid(mockUserGeographyPop); + + // Then + expect(result).toBe(true); + }); + + test('given geography with empty ID when checking validity then returns false', () => { + // When + const result = UserPopulationOps.isValid(mockUserGeographyPopInvalid); + + // Then + expect(result).toBe(false); + }); + + test('given geography with no country ID when checking validity then returns false', () => { + // When + const result = UserPopulationOps.isValid(mockUserGeographyPopNoCountry); + + // Then + expect(result).toBe(false); + }); + }); +});