import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; const { mockUseApi } = vi.hoisted(() => ({ mockUseApi: vi.fn(), })); const { mockCreateHousehold, mockJoinHousehold, mockGetHousehold, mockUpdateHousehold, mockGenerateInviteCode, } = vi.hoisted(() => ({ mockCreateHousehold: vi.fn(), mockJoinHousehold: vi.fn(), mockGetHousehold: vi.fn(), mockUpdateHousehold: vi.fn(), mockGenerateInviteCode: vi.fn(), })); vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi })); vi.mock('@/services/households', () => ({ createHousehold: mockCreateHousehold, joinHousehold: mockJoinHousehold, getHousehold: mockGetHousehold, updateHousehold: mockUpdateHousehold, generateInviteCode: mockGenerateInviteCode, })); import SettingsPage from '../../../../src/app/(dashboard)/settings/page'; const household = { name: 'My House', inviteCode: 'ABC123', members: [{ userId: 'u-1', role: 'admin' }], }; beforeEach(() => { vi.clearAllMocks(); }); describe('SettingsPage', () => { it('shows loading skeleton when loading', () => { mockUseApi.mockReturnValue({ householdId: null, isLoading: true, refreshProfile: vi.fn() }); render(); expect(screen.getByText('Settings')).toBeInTheDocument(); expect(screen.queryByText('Household')).not.toBeInTheDocument(); }); it('shows create/join forms when no household', () => { mockUseApi.mockReturnValue({ householdId: null, isLoading: false, refreshProfile: vi.fn() }); render(); expect(screen.getByPlaceholderText('Household name')).toBeInTheDocument(); expect(screen.getByPlaceholderText('Invite code')).toBeInTheDocument(); }); it('shows account section always', () => { mockUseApi.mockReturnValue({ householdId: null, isLoading: false, refreshProfile: vi.fn() }); render(); expect(screen.getByText('Account')).toBeInTheDocument(); expect(screen.getByRole('link', { name: /Manage Keycloak Account/ })).toBeInTheDocument(); }); it('creates a household on form submit', async () => { const refreshProfile = vi.fn(); mockUseApi.mockReturnValue({ householdId: null, isLoading: false, refreshProfile }); mockCreateHousehold.mockResolvedValue({}); render(); await userEvent.type(screen.getByPlaceholderText('Household name'), 'New Home'); await userEvent.click(screen.getByRole('button', { name: 'Create' })); await waitFor(() => expect(mockCreateHousehold).toHaveBeenCalledWith('New Home')); expect(refreshProfile).toHaveBeenCalled(); }); it('shows error when create fails', async () => { mockUseApi.mockReturnValue({ householdId: null, isLoading: false, refreshProfile: vi.fn() }); mockCreateHousehold.mockRejectedValue(new Error('Already exists')); render(); await userEvent.type(screen.getByPlaceholderText('Household name'), 'New Home'); await userEvent.click(screen.getByRole('button', { name: 'Create' })); await waitFor(() => expect(screen.getByText('Already exists')).toBeInTheDocument()); }); it('joins a household on form submit', async () => { const refreshProfile = vi.fn(); mockUseApi.mockReturnValue({ householdId: null, isLoading: false, refreshProfile }); mockJoinHousehold.mockResolvedValue({}); render(); await userEvent.type(screen.getByPlaceholderText('Invite code'), 'XYZ999'); await userEvent.click(screen.getByRole('button', { name: 'Join' })); await waitFor(() => expect(mockJoinHousehold).toHaveBeenCalledWith('XYZ999')); expect(refreshProfile).toHaveBeenCalled(); }); it('shows error when join fails', async () => { mockUseApi.mockReturnValue({ householdId: null, isLoading: false, refreshProfile: vi.fn() }); mockJoinHousehold.mockRejectedValue(new Error('Invalid code')); render(); await userEvent.type(screen.getByPlaceholderText('Invite code'), 'BAD'); await userEvent.click(screen.getByRole('button', { name: 'Join' })); await waitFor(() => expect(screen.getByText('Invalid code')).toBeInTheDocument()); }); it('loads and displays household details when householdId exists', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false, refreshProfile: vi.fn() }); mockGetHousehold.mockResolvedValue(household); render(); await waitFor(() => expect(screen.getByText('My House')).toBeInTheDocument()); expect(screen.getByText('ABC123')).toBeInTheDocument(); expect(screen.getByText('1')).toBeInTheDocument(); // member count }); it('shows loading household details text initially', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false, refreshProfile: vi.fn() }); mockGetHousehold.mockReturnValue(new Promise(() => {})); // never resolves render(); expect(screen.getByText('Loading household details...')).toBeInTheDocument(); }); it('allows editing the household name', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false, refreshProfile: vi.fn() }); mockGetHousehold.mockResolvedValue(household); mockUpdateHousehold.mockResolvedValue({ ...household, name: 'Updated Home' }); render(); await waitFor(() => screen.getByText('My House')); await userEvent.click(screen.getByRole('button', { name: 'Edit' })); const input = screen.getByDisplayValue('My House'); await userEvent.clear(input); await userEvent.type(input, 'Updated Home'); await userEvent.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => expect(mockUpdateHousehold).toHaveBeenCalledWith('hh1', { name: 'Updated Home' }), ); }); it('shows error when name update fails', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false, refreshProfile: vi.fn() }); mockGetHousehold.mockResolvedValue(household); mockUpdateHousehold.mockRejectedValue(new Error('Name taken')); render(); await waitFor(() => screen.getByText('My House')); await userEvent.click(screen.getByRole('button', { name: 'Edit' })); const input = screen.getByDisplayValue('My House'); await userEvent.clear(input); await userEvent.type(input, 'Other Home'); await userEvent.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => expect(screen.getByText('Name taken')).toBeInTheDocument()); }); it('cancels editing name', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false, refreshProfile: vi.fn() }); mockGetHousehold.mockResolvedValue(household); render(); await waitFor(() => screen.getByText('My House')); await userEvent.click(screen.getByRole('button', { name: 'Edit' })); await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); expect(screen.getByText('My House')).toBeInTheDocument(); expect(screen.queryByDisplayValue('My House')).not.toBeInTheDocument(); }); it('regenerates invite code', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false, refreshProfile: vi.fn() }); mockGetHousehold.mockResolvedValue(household); mockGenerateInviteCode.mockResolvedValue({ ...household, inviteCode: 'NEW999' }); render(); await waitFor(() => screen.getByText('ABC123')); await userEvent.click(screen.getByRole('button', { name: 'Regenerate' })); await waitFor(() => expect(screen.getByText('NEW999')).toBeInTheDocument()); }); it('shows error when regenerate fails', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false, refreshProfile: vi.fn() }); mockGetHousehold.mockResolvedValue(household); mockGenerateInviteCode.mockRejectedValue(new Error('Server error')); render(); await waitFor(() => screen.getByText('ABC123')); await userEvent.click(screen.getByRole('button', { name: 'Regenerate' })); await waitFor(() => expect(screen.getByText('Server error')).toBeInTheDocument()); }); it('saves unchanged name without calling API', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false, refreshProfile: vi.fn() }); mockGetHousehold.mockResolvedValue(household); render(); await waitFor(() => screen.getByText('My House')); await userEvent.click(screen.getByRole('button', { name: 'Edit' })); await userEvent.click(screen.getByRole('button', { name: 'Save' })); expect(mockUpdateHousehold).not.toHaveBeenCalled(); }); it('shows fallback error when non-Error thrown on create household', async () => { mockUseApi.mockReturnValue({ householdId: null, isLoading: false, refreshProfile: vi.fn() }); mockCreateHousehold.mockRejectedValue('create failed'); render(); await userEvent.type(screen.getByPlaceholderText('Household name'), 'My Home'); await userEvent.click(screen.getByRole('button', { name: 'Create' })); await waitFor(() => expect(screen.getByText('Failed to create household')).toBeInTheDocument()); }); it('shows fallback error when non-Error thrown on join household', async () => { mockUseApi.mockReturnValue({ householdId: null, isLoading: false, refreshProfile: vi.fn() }); mockJoinHousehold.mockRejectedValue('join failed'); render(); await userEvent.type(screen.getByPlaceholderText('Invite code'), 'XYZ123'); await userEvent.click(screen.getByRole('button', { name: 'Join' })); await waitFor(() => expect(screen.getByText('Failed to join household')).toBeInTheDocument()); }); it('shows fallback error when non-Error thrown on name update', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false, refreshProfile: vi.fn() }); mockGetHousehold.mockResolvedValue(household); mockUpdateHousehold.mockRejectedValue('update failed'); render(); await waitFor(() => screen.getByText('My House')); await userEvent.click(screen.getByRole('button', { name: 'Edit' })); const input = screen.getByDisplayValue('My House'); await userEvent.clear(input); await userEvent.type(input, 'New Name'); await userEvent.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => expect(screen.getByText('Failed to update name')).toBeInTheDocument()); }); it('shows fallback error when non-Error thrown on regenerate', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false, refreshProfile: vi.fn() }); mockGetHousehold.mockResolvedValue(household); mockGenerateInviteCode.mockRejectedValue('regen failed'); render(); await waitFor(() => screen.getByText('My House')); await userEvent.click(screen.getByRole('button', { name: 'Regenerate' })); await waitFor(() => expect(screen.getByText('Failed to regenerate invite code')).toBeInTheDocument(), ); }); it('shows validation error when saving empty name', async () => { mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false, refreshProfile: vi.fn() }); mockGetHousehold.mockResolvedValue(household); render(); await waitFor(() => screen.getByText('My House')); await userEvent.click(screen.getByRole('button', { name: 'Edit' })); const input = screen.getByDisplayValue('My House'); await userEvent.clear(input); await userEvent.click(screen.getByRole('button', { name: 'Save' })); expect(screen.getByText('Name cannot be empty')).toBeInTheDocument(); }); });