2026-05-14 14:47:23 +09:00
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
2026-05-19 10:13:40 +09:00
|
|
|
import { SWRConfig } from 'swr';
|
2026-05-14 14:47:23 +09:00
|
|
|
|
|
|
|
|
const { mockUseApi } = vi.hoisted(() => ({
|
|
|
|
|
mockUseApi: vi.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const { mockListPantryItems, mockTransitionPantryItem, mockDeletePantryItem } = vi.hoisted(() => ({
|
|
|
|
|
mockListPantryItems: vi.fn(),
|
|
|
|
|
mockTransitionPantryItem: vi.fn(),
|
|
|
|
|
mockDeletePantryItem: vi.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
vi.mock('@/lib/useApi', () => ({ useApi: mockUseApi }));
|
|
|
|
|
|
|
|
|
|
vi.mock('@/services/pantry', () => ({
|
|
|
|
|
listPantryItems: mockListPantryItems,
|
|
|
|
|
transitionPantryItem: mockTransitionPantryItem,
|
|
|
|
|
deletePantryItem: mockDeletePantryItem,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
vi.mock('next/link', () => {
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
return { default: (props: any) => props.children };
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-19 11:06:03 +09:00
|
|
|
import PantryPage from '../../../../src/app/(dashboard)/pantry/page';
|
2026-05-14 14:47:23 +09:00
|
|
|
|
|
|
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
|
|
2026-05-19 10:13:40 +09:00
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
|
|
|
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>{children}</SWRConfig>
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-14 14:47:23 +09:00
|
|
|
const SAMPLE_ITEM = {
|
|
|
|
|
_id: 'pi-1',
|
|
|
|
|
householdId: 'hh1',
|
|
|
|
|
productId: 'p1',
|
|
|
|
|
productName: 'Whole Milk',
|
|
|
|
|
storageLocation: 'fridge',
|
|
|
|
|
quantity: 1,
|
|
|
|
|
unit: 'liter',
|
|
|
|
|
purchaseDate: '2026-05-01T00:00:00.000Z',
|
|
|
|
|
status: 'sealed',
|
|
|
|
|
freshnessEstimate: {
|
|
|
|
|
estimatedExpiryDate: '2026-05-15T00:00:00.000Z',
|
|
|
|
|
daysRemaining: 11,
|
|
|
|
|
urgency: 'fresh',
|
|
|
|
|
source: 'rule',
|
|
|
|
|
},
|
|
|
|
|
createdBy: 'u1',
|
|
|
|
|
createdAt: '2026-05-01T00:00:00.000Z',
|
|
|
|
|
updatedAt: '2026-05-01T00:00:00.000Z',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
describe('PantryPage', () => {
|
|
|
|
|
it('shows loading skeleton when session is loading', () => {
|
|
|
|
|
mockUseApi.mockReturnValue({ householdId: null, isLoading: true });
|
|
|
|
|
|
2026-05-19 10:13:40 +09:00
|
|
|
render(<PantryPage />, { wrapper });
|
2026-05-14 14:47:23 +09:00
|
|
|
|
|
|
|
|
expect(screen.getByText('Pantry')).toBeInTheDocument();
|
|
|
|
|
expect(screen.queryByText('All')).not.toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('shows household prompt when no householdId', () => {
|
|
|
|
|
mockUseApi.mockReturnValue({ householdId: null, isLoading: false });
|
|
|
|
|
|
2026-05-19 10:13:40 +09:00
|
|
|
render(<PantryPage />, { wrapper });
|
2026-05-14 14:47:23 +09:00
|
|
|
|
|
|
|
|
expect(screen.getByText(/create or join a household/)).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('renders pantry items when household exists', async () => {
|
|
|
|
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
|
|
|
|
mockListPantryItems.mockResolvedValue({
|
|
|
|
|
data: [SAMPLE_ITEM],
|
|
|
|
|
pagination: { cursor: null, hasMore: false },
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-19 10:13:40 +09:00
|
|
|
render(<PantryPage />, { wrapper });
|
2026-05-14 14:47:23 +09:00
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByText('Whole Milk')).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('shows empty state when no items', async () => {
|
|
|
|
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
|
|
|
|
mockListPantryItems.mockResolvedValue({
|
|
|
|
|
data: [],
|
|
|
|
|
pagination: { cursor: null, hasMore: false },
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-19 10:13:40 +09:00
|
|
|
render(<PantryPage />, { wrapper });
|
2026-05-14 14:47:23 +09:00
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByText(/No pantry items yet/)).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('shows freshness badge', async () => {
|
|
|
|
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
|
|
|
|
mockListPantryItems.mockResolvedValue({
|
|
|
|
|
data: [SAMPLE_ITEM],
|
|
|
|
|
pagination: { cursor: null, hasMore: false },
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-19 10:13:40 +09:00
|
|
|
render(<PantryPage />, { wrapper });
|
2026-05-14 14:47:23 +09:00
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByText('Fresh')).toBeInTheDocument();
|
|
|
|
|
expect(screen.getByText('11d left')).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('shows overdue text for negative days', async () => {
|
|
|
|
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
|
|
|
|
mockListPantryItems.mockResolvedValue({
|
|
|
|
|
data: [
|
|
|
|
|
{
|
|
|
|
|
...SAMPLE_ITEM,
|
|
|
|
|
_id: 'pi-2',
|
|
|
|
|
freshnessEstimate: {
|
|
|
|
|
...SAMPLE_ITEM.freshnessEstimate,
|
|
|
|
|
daysRemaining: -2,
|
|
|
|
|
urgency: 'expired',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
pagination: { cursor: null, hasMore: false },
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-19 10:13:40 +09:00
|
|
|
render(<PantryPage />, { wrapper });
|
2026-05-14 14:47:23 +09:00
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByText('2d overdue')).toBeInTheDocument();
|
|
|
|
|
expect(screen.getByText('Expired')).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('shows transition buttons for active items', async () => {
|
|
|
|
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
|
|
|
|
mockListPantryItems.mockResolvedValue({
|
|
|
|
|
data: [SAMPLE_ITEM],
|
|
|
|
|
pagination: { cursor: null, hasMore: false },
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-19 10:13:40 +09:00
|
|
|
render(<PantryPage />, { wrapper });
|
2026-05-14 14:47:23 +09:00
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByText('Open')).toBeInTheDocument();
|
|
|
|
|
expect(screen.getByText('Consume')).toBeInTheDocument();
|
|
|
|
|
expect(screen.getByText('Discard')).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('handles transition click', async () => {
|
|
|
|
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
|
|
|
|
mockListPantryItems.mockResolvedValue({
|
|
|
|
|
data: [SAMPLE_ITEM],
|
|
|
|
|
pagination: { cursor: null, hasMore: false },
|
|
|
|
|
});
|
|
|
|
|
mockTransitionPantryItem.mockResolvedValue({
|
|
|
|
|
...SAMPLE_ITEM,
|
|
|
|
|
status: 'opened',
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-19 10:13:40 +09:00
|
|
|
render(<PantryPage />, { wrapper });
|
2026-05-14 14:47:23 +09:00
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByText('Open')).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByText('Open'));
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(mockTransitionPantryItem).toHaveBeenCalledWith('hh1', 'pi-1', { status: 'opened' });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('handles delete with confirmation', async () => {
|
|
|
|
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
|
|
|
|
mockListPantryItems.mockResolvedValue({
|
|
|
|
|
data: [SAMPLE_ITEM],
|
|
|
|
|
pagination: { cursor: null, hasMore: false },
|
|
|
|
|
});
|
|
|
|
|
mockDeletePantryItem.mockResolvedValue(undefined);
|
|
|
|
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
|
|
|
|
|
2026-05-19 10:13:40 +09:00
|
|
|
render(<PantryPage />, { wrapper });
|
2026-05-14 14:47:23 +09:00
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByText('Delete')).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByText('Delete'));
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(mockDeletePantryItem).toHaveBeenCalledWith('hh1', 'pi-1');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('does not delete when confirm is cancelled', async () => {
|
|
|
|
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
|
|
|
|
mockListPantryItems.mockResolvedValue({
|
|
|
|
|
data: [SAMPLE_ITEM],
|
|
|
|
|
pagination: { cursor: null, hasMore: false },
|
|
|
|
|
});
|
|
|
|
|
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
|
|
|
|
|
2026-05-19 10:13:40 +09:00
|
|
|
render(<PantryPage />, { wrapper });
|
2026-05-14 14:47:23 +09:00
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByText('Delete')).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByText('Delete'));
|
|
|
|
|
|
|
|
|
|
expect(mockDeletePantryItem).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('filters by storage tab', async () => {
|
|
|
|
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
|
|
|
|
mockListPantryItems.mockResolvedValue({
|
|
|
|
|
data: [],
|
|
|
|
|
pagination: { cursor: null, hasMore: false },
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-19 10:13:40 +09:00
|
|
|
render(<PantryPage />, { wrapper });
|
2026-05-14 14:47:23 +09:00
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByText('Fridge')).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByText('Fridge'));
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(mockListPantryItems).toHaveBeenCalledWith(
|
|
|
|
|
'hh1',
|
|
|
|
|
expect.objectContaining({ storageLocation: 'fridge' }),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('filters by status select', async () => {
|
|
|
|
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
|
|
|
|
mockListPantryItems.mockResolvedValue({
|
|
|
|
|
data: [],
|
|
|
|
|
pagination: { cursor: null, hasMore: false },
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-19 10:13:40 +09:00
|
|
|
render(<PantryPage />, { wrapper });
|
2026-05-14 14:47:23 +09:00
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByDisplayValue('All statuses')).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
fireEvent.change(screen.getByDisplayValue('All statuses'), { target: { value: 'opened' } });
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(mockListPantryItems).toHaveBeenCalledWith(
|
|
|
|
|
'hh1',
|
|
|
|
|
expect.objectContaining({ status: 'opened' }),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('shows error when fetch fails', async () => {
|
|
|
|
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
|
|
|
|
mockListPantryItems.mockRejectedValue(new Error('Network error'));
|
|
|
|
|
|
2026-05-19 10:13:40 +09:00
|
|
|
render(<PantryPage />, { wrapper });
|
2026-05-14 14:47:23 +09:00
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByText('Network error')).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('shows error when transition fails', async () => {
|
|
|
|
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
|
|
|
|
mockListPantryItems.mockResolvedValue({
|
|
|
|
|
data: [SAMPLE_ITEM],
|
|
|
|
|
pagination: { cursor: null, hasMore: false },
|
|
|
|
|
});
|
|
|
|
|
mockTransitionPantryItem.mockRejectedValue(new Error('Transition failed'));
|
|
|
|
|
|
|
|
|
|
render(<PantryPage />);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByText('Open')).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByText('Open'));
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByText('Transition failed')).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('shows filter empty state message', async () => {
|
|
|
|
|
mockUseApi.mockReturnValue({ householdId: 'hh1', isLoading: false });
|
|
|
|
|
mockListPantryItems.mockResolvedValue({
|
|
|
|
|
data: [],
|
|
|
|
|
pagination: { cursor: null, hasMore: false },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
render(<PantryPage />);
|
|
|
|
|
|
|
|
|
|
// Click a filter tab to trigger filter state
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByText('Fridge')).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByText('Fridge'));
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByText(/No items match your filters/)).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|