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

551 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
const { mockUseApi } = vi.hoisted(() => ({
mockUseApi: vi.fn(),
}));
const { mockListStores, mockCreateStore, mockUpdateStore, mockDeactivateStore } = vi.hoisted(
() => ({
mockListStores: vi.fn(),
mockCreateStore: vi.fn(),
mockUpdateStore: vi.fn(),
mockDeactivateStore: vi.fn(),
}),
);
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
vi.mock('@/services/stores', () => ({
listStores: mockListStores,
createStore: mockCreateStore,
updateStore: mockUpdateStore,
deactivateStore: mockDeactivateStore,
}));
vi.mock('next/link', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { default: (props: any) => props.children };
});
import StoresPage from '../../../../src/app/(dashboard)/stores/page';
beforeEach(() => vi.clearAllMocks());
describe('StoresPage', () => {
it('shows loading skeleton when session is loading', () => {
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
render(<StoresPage />);
expect(screen.getByText('Stores')).toBeInTheDocument();
expect(screen.queryByText('Add Store')).not.toBeInTheDocument();
});
it('shows household prompt when no householdId', () => {
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
render(<StoresPage />);
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
});
it('renders store list when household exists', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockResolvedValue({
data: [
{
_id: 'st-1',
name: 'Walgreens',
tags: ['pharmacy'],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
createdBy: 'u-1',
},
],
pagination: { cursor: null, hasMore: false },
});
render(<StoresPage />);
await waitFor(() => {
expect(screen.getByText('Walgreens')).toBeInTheDocument();
});
});
it('shows empty state when no stores', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
render(<StoresPage />);
await waitFor(() => {
expect(screen.getByText(/No stores yet/)).toBeInTheDocument();
});
});
it('shows Add Store form when button clicked', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockResolvedValue({
data: [],
pagination: { cursor: null, hasMore: false },
});
render(<StoresPage />);
await waitFor(() => {
expect(screen.getByText('Add Store')).toBeInTheDocument();
});
await userEvent.click(screen.getByText('Add Store'));
expect(screen.getByPlaceholderText('e.g. Walgreens')).toBeInTheDocument();
});
it('shows error when store list fails to load', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockRejectedValue(new Error('Network error'));
render(<StoresPage />);
await waitFor(() => {
expect(screen.getByText('Network error')).toBeInTheDocument();
});
});
it('deactivates store after confirmation', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockResolvedValue({
data: [
{
_id: 'st-1',
name: 'CVS',
tags: [],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
createdBy: 'u-1',
},
],
pagination: { cursor: null, hasMore: false },
});
mockDeactivateStore.mockResolvedValue({});
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<StoresPage />);
await waitFor(() => {
expect(screen.getByText('CVS')).toBeInTheDocument();
});
await userEvent.click(screen.getByTitle('Deactivate'));
expect(mockDeactivateStore).toHaveBeenCalledWith('hh1', 'st-1');
});
it('creates a store on form submit', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } });
mockCreateStore.mockResolvedValue({
_id: 'st-new',
name: 'Walmart',
tags: [],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
createdBy: 'u-1',
});
render(<StoresPage />);
await waitFor(() => screen.getByText('Add Store'));
await userEvent.click(screen.getByText('Add Store'));
await waitFor(() => screen.getByPlaceholderText('e.g. Walgreens'));
fireEvent.change(screen.getByPlaceholderText('e.g. Walgreens'), {
target: { value: 'Walmart' },
});
fireEvent.submit(screen.getByPlaceholderText('e.g. Walgreens').closest('form')!);
await waitFor(() =>
expect(mockCreateStore).toHaveBeenCalledWith(
'hh1',
expect.objectContaining({ name: 'Walmart' }),
),
);
});
it('shows error when create store fails', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } });
mockCreateStore.mockRejectedValue(new Error('Store already exists'));
render(<StoresPage />);
await waitFor(() => screen.getByText('Add Store'));
await userEvent.click(screen.getByText('Add Store'));
await waitFor(() => screen.getByPlaceholderText('e.g. Walgreens'));
fireEvent.change(screen.getByPlaceholderText('e.g. Walgreens'), {
target: { value: 'Walmart' },
});
fireEvent.submit(screen.getByPlaceholderText('e.g. Walgreens').closest('form')!);
await waitFor(() => expect(screen.getByText('Store already exists')).toBeInTheDocument());
});
it('opens edit form for a store', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockResolvedValue({
data: [
{
_id: 'st-1',
name: 'CVS',
tags: [],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
createdBy: 'u-1',
},
],
pagination: { cursor: null, hasMore: false },
});
render(<StoresPage />);
await waitFor(() => screen.getByText('CVS'));
await userEvent.click(screen.getByTitle('Edit'));
expect(screen.getByDisplayValue('CVS')).toBeInTheDocument();
expect(screen.getByText('Edit Store')).toBeInTheDocument();
});
it('saves edited store', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockResolvedValue({
data: [
{
_id: 'st-1',
name: 'CVS',
tags: [],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
createdBy: 'u-1',
},
],
pagination: { cursor: null, hasMore: false },
});
mockUpdateStore.mockResolvedValue({});
render(<StoresPage />);
await waitFor(() => screen.getByText('CVS'));
await userEvent.click(screen.getByTitle('Edit'));
await waitFor(() => screen.getByDisplayValue('CVS'));
fireEvent.change(screen.getByDisplayValue('CVS'), { target: { value: 'CVS Pharmacy' } });
fireEvent.submit(screen.getByDisplayValue('CVS Pharmacy').closest('form')!);
await waitFor(() =>
expect(mockUpdateStore).toHaveBeenCalledWith(
'hh1',
'st-1',
expect.objectContaining({ name: 'CVS Pharmacy' }),
),
);
});
it('dismisses error', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockRejectedValue(new Error('Network error'));
render(<StoresPage />);
await waitFor(() => screen.getByText('Network error'));
await userEvent.click(screen.getByText('Dismiss'));
expect(screen.queryByText('Network error')).not.toBeInTheDocument();
});
it('shows error when deactivate fails', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockResolvedValue({
data: [
{
_id: 'st-1',
name: 'CVS',
tags: [],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
createdBy: 'u-1',
},
],
pagination: { cursor: null, hasMore: false },
});
mockDeactivateStore.mockRejectedValue(new Error('Deactivate failed'));
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<StoresPage />);
await waitFor(() => screen.getByTitle('Deactivate'));
await userEvent.click(screen.getByTitle('Deactivate'));
await waitFor(() => expect(screen.getByText('Deactivate failed')).toBeInTheDocument());
});
it('cancels the create store form', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } });
render(<StoresPage />);
await waitFor(() => screen.getByText('Add Store'));
await userEvent.click(screen.getByText('Add Store'));
await waitFor(() => screen.getByPlaceholderText('e.g. Walgreens'));
await userEvent.click(
screen.getAllByRole('button', { name: 'Cancel' })[
screen.getAllByRole('button', { name: 'Cancel' }).length - 1
]!,
);
expect(screen.queryByPlaceholderText('e.g. Walgreens')).not.toBeInTheDocument();
});
it('cancels the edit store form', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockResolvedValue({
data: [
{
_id: 'st-1',
name: 'CVS',
tags: [],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
createdBy: 'u-1',
},
],
pagination: { cursor: null, hasMore: false },
});
render(<StoresPage />);
await waitFor(() => screen.getByText('CVS'));
await userEvent.click(screen.getByTitle('Edit'));
await waitFor(() => screen.getByDisplayValue('CVS'));
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(screen.queryByDisplayValue('CVS')).not.toBeInTheDocument();
});
it('filters stores by tag', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockResolvedValue({
data: [
{
_id: 'st-1',
name: 'Walgreens',
tags: ['pharmacy'],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
createdBy: 'u-1',
},
{
_id: 'st-2',
name: 'Costco',
tags: ['supermarket'],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
createdBy: 'u-1',
},
],
pagination: { cursor: null, hasMore: false },
});
render(<StoresPage />);
await waitFor(() => screen.getByText('Walgreens'));
expect(screen.getByText('Costco')).toBeInTheDocument();
fireEvent.change(screen.getByDisplayValue('All tags'), { target: { value: 'pharmacy' } });
await waitFor(() => expect(screen.queryByText('Costco')).not.toBeInTheDocument());
expect(screen.getByText('Walgreens')).toBeInTheDocument();
});
it('changes store form fields (notes, address, url)', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } });
render(<StoresPage />);
await waitFor(() => screen.getByText('Add Store'));
await userEvent.click(screen.getByText('Add Store'));
await waitFor(() => screen.getByPlaceholderText('e.g. Walgreens'));
fireEvent.change(screen.getByPlaceholderText('123 Main St'), {
target: { value: '456 Oak Ave' },
});
fireEvent.change(screen.getByPlaceholderText('Any notes'), {
target: { value: 'Good prices' },
});
expect(screen.getByPlaceholderText('e.g. Walgreens')).toBeInTheDocument();
});
it('toggles a preset tag and adds a custom tag', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } });
render(<StoresPage />);
await waitFor(() => screen.getByText('Add Store'));
await userEvent.click(screen.getByText('Add Store'));
await waitFor(() => screen.getByPlaceholderText('Custom tag...'));
// Toggle a preset tag (e.g. 'pharmacy')
await userEvent.click(screen.getByRole('button', { name: 'pharmacy' }));
// Toggle it off again
await userEvent.click(screen.getByRole('button', { name: 'pharmacy' }));
// Add a custom tag via button
fireEvent.change(screen.getByPlaceholderText('Custom tag...'), { target: { value: 'mytag' } });
await userEvent.click(screen.getByRole('button', { name: 'Add' }));
await waitFor(() => expect(screen.getByText('mytag')).toBeInTheDocument());
// Remove it via the × button
await userEvent.click(screen.getByText('×'));
expect(screen.queryByText('mytag')).not.toBeInTheDocument();
});
it('adds custom tag via Enter key', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockResolvedValue({ data: [], pagination: { cursor: null, hasMore: false } });
render(<StoresPage />);
await waitFor(() => screen.getByText('Add Store'));
await userEvent.click(screen.getByText('Add Store'));
await waitFor(() => screen.getByPlaceholderText('Custom tag...'));
fireEvent.change(screen.getByPlaceholderText('Custom tag...'), { target: { value: 'keytag' } });
fireEvent.keyDown(screen.getByPlaceholderText('Custom tag...'), { key: 'Enter' });
await waitFor(() => expect(screen.getByText('keytag')).toBeInTheDocument());
});
it('changes search filter', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockResolvedValue({
data: [
{
_id: 'st-1',
name: 'Walgreens',
tags: [],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
createdBy: 'u-1',
},
{
_id: 'st-2',
name: 'CVS',
tags: [],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
createdBy: 'u-1',
},
],
pagination: { cursor: null, hasMore: false },
});
render(<StoresPage />);
await waitFor(() => screen.getByText('Walgreens'));
fireEvent.change(screen.getByPlaceholderText('Search stores...'), { target: { value: 'wal' } });
await waitFor(() => expect(screen.queryByText('CVS')).not.toBeInTheDocument());
expect(screen.getByText('Walgreens')).toBeInTheDocument();
});
it('toggles isActive in edit form', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockResolvedValue({
data: [
{
_id: 'st-1',
name: 'CVS',
tags: [],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
createdBy: 'u-1',
},
],
pagination: { cursor: null, hasMore: false },
});
mockUpdateStore.mockResolvedValue({});
render(<StoresPage />);
await waitFor(() => screen.getByText('CVS'));
await userEvent.click(screen.getByTitle('Edit'));
await waitFor(() => screen.getByLabelText('Active'));
fireEvent.click(screen.getByLabelText('Active'));
expect(screen.getByLabelText('Active')).not.toBeChecked();
});
it('shows inactive stores when toggle checked', async () => {
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
mockListStores.mockResolvedValue({
data: [
{
_id: 'st-1',
name: 'Active Store',
tags: [],
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
createdBy: 'u-1',
},
{
_id: 'st-2',
name: 'Old Store',
tags: [],
isActive: false,
createdAt: '2026-01-01T00:00:00.000Z',
householdId: 'hh1',
createdBy: 'u-1',
},
],
pagination: { cursor: null, hasMore: false },
});
render(<StoresPage />);
await waitFor(() => screen.getByText('Active Store'));
expect(screen.queryByText('Old Store')).not.toBeInTheDocument();
fireEvent.click(screen.getByLabelText('Show inactive'));
expect(screen.getByText('Old Store')).toBeInTheDocument();
});
});