MeshiTrack/packages/web/tests/app/(dashboard)/settings/page.test.tsx

281 lines
12 KiB
TypeScript
Raw Normal View History

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(),
}));
2026-04-26 18:44:59 +09:00
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,
}));
2026-05-19 11:06:03 +09:00
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(<SettingsPage />);
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(<SettingsPage />);
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(<SettingsPage />);
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(<SettingsPage />);
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(<SettingsPage />);
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(<SettingsPage />);
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(<SettingsPage />);
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(<SettingsPage />);
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(<SettingsPage />);
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(<SettingsPage />);
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' }));
2026-04-26 18:44:59 +09:00
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(<SettingsPage />);
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(<SettingsPage />);
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(<SettingsPage />);
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(<SettingsPage />);
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(<SettingsPage />);
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(<SettingsPage />);
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(<SettingsPage />);
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(<SettingsPage />);
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(<SettingsPage />);
await waitFor(() => screen.getByText('My House'));
await userEvent.click(screen.getByRole('button', { name: 'Regenerate' }));
2026-04-26 18:44:59 +09:00
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(<SettingsPage />);
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();
});
});