import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Mock next-intl — passthrough t(key) => key (Pitfall: must be before imports) vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key, })); // Mock favorites-api — all 4 functions as controllable vi.fn() spies vi.mock('@/lib/favorites-api', () => ({ fetchFavorites: vi.fn(), createFavorite: vi.fn(), updateFavorite: vi.fn(), deleteFavorite: vi.fn(), })); // Mock dashboard-api for updateWidgetConfig (view mode persistence) vi.mock('@/lib/dashboard-api', () => ({ updateWidgetConfig: vi.fn().mockResolvedValue(undefined), })); import { fetchFavorites, createFavorite, updateFavorite, deleteFavorite, } from '@/lib/favorites-api'; // Must import AFTER mocks import { FavoritesWidget } from './favorites-widget'; // Typed mock helpers const mockFetch = fetchFavorites as ReturnType; const mockCreate = createFavorite as ReturnType; const mockUpdate = updateFavorite as ReturnType; const mockDelete = deleteFavorite as ReturnType; const BASE_FAVORITES = [ { id: 'fav-id-1', widgetId: 'fav-1', title: 'GitHub', url: 'https://github.com', iconUrl: 'https://github.com/favicon.ico', position: 0, }, { id: 'fav-id-2', widgetId: 'fav-1', title: 'Notion', url: 'https://notion.so', iconUrl: null, // null iconUrl → letter fallback 'N' position: 1, }, ]; describe('FavoritesWidget', () => { beforeEach(() => { vi.clearAllMocks(); // Default: return the base favorites list mockFetch.mockResolvedValue([...BASE_FAVORITES]); mockCreate.mockResolvedValue({ id: 'fav-id-new', widgetId: 'fav-1', title: 'New Link', url: 'https://example.com', iconUrl: null, position: 2, }); mockUpdate.mockResolvedValue({ ...BASE_FAVORITES[0], title: 'GitHub Updated', }); mockDelete.mockResolvedValue(undefined); }); afterEach(() => { vi.restoreAllMocks(); }); it('calls fetchFavorites with instanceId on mount and renders link titles', async () => { render( , ); // fetchFavorites must be called with the instanceId (widgetId scope — Pitfall 3) expect(mockFetch).toHaveBeenCalledWith('fav-1'); // After async resolution, link titles should be visible await waitFor(() => { expect(screen.getByText('GitHub')).toBeInTheDocument(); expect(screen.getByText('Notion')).toBeInTheDocument(); }); }); it('shows empty message when fetchFavorites resolves to empty array', async () => { mockFetch.mockResolvedValue([]); render( , ); await waitFor(() => { expect(screen.getByText('favorites.empty')).toBeInTheDocument(); }); }); it('add: submit form in edit mode calls createFavorite with widgetId, title, url', async () => { mockFetch.mockResolvedValue([]); render( , ); await waitFor(() => { expect(screen.getByText('favorites.empty')).toBeInTheDocument(); }); // Fill in the add form (inputs are identified by placeholder translation keys) const titleInput = screen.getByPlaceholderText('favorites.addTitle'); const urlInput = screen.getByPlaceholderText('favorites.addUrl'); // Use sync fireEvent — RTL will flush state updates automatically act(() => { fireEvent.change(titleInput, { target: { value: 'New Link' } }); fireEvent.change(urlInput, { target: { value: 'https://example.com' } }); }); // Submit the form const addButton = screen.getByRole('button', { name: /favorites\.addButton/i }); act(() => { fireEvent.click(addButton); }); // waitFor polls until the async chain (createFavorite → setFavorites) completes await waitFor(() => { expect(mockCreate).toHaveBeenCalledWith( expect.objectContaining({ widgetId: 'fav-1', title: 'New Link', url: 'https://example.com', }), ); }); // The new link title should appear in the list await waitFor(() => { expect(screen.getByText('New Link')).toBeInTheDocument(); }); }); it('edit: clicking edit, changing title, saving calls updateFavorite with id and new title', async () => { render( , ); await waitFor(() => { expect(screen.getByText('GitHub')).toBeInTheDocument(); }); // Click the edit button for 'GitHub' (aria-label is the translation key) const editButtons = screen.getAllByRole('button', { name: /favorites\.editButton/i, }); act(() => { fireEvent.click(editButtons[0]); }); // Change the title in the inline edit form await waitFor(() => { expect(screen.getByDisplayValue('GitHub')).toBeInTheDocument(); }); const editTitleInput = screen.getByDisplayValue('GitHub'); act(() => { fireEvent.change(editTitleInput, { target: { value: 'GitHub Updated' } }); }); // Click Save await waitFor(() => { expect(screen.getByRole('button', { name: /favorites\.saveButton/i })).toBeInTheDocument(); }); const saveButton = screen.getByRole('button', { name: /favorites\.saveButton/i, }); act(() => { fireEvent.click(saveButton); }); // updateFavorite called with the favorite id and new title await waitFor(() => { expect(mockUpdate).toHaveBeenCalledWith( 'fav-id-1', expect.objectContaining({ title: 'GitHub Updated' }), ); }); }); it('delete: clicking delete calls deleteFavorite with id and removes the row', async () => { render( , ); await waitFor(() => { expect(screen.getByText('GitHub')).toBeInTheDocument(); }); // Click the delete button for 'GitHub' (first delete button) const deleteButtons = screen.getAllByRole('button', { name: /favorites\.deleteButton/i, }); act(() => { fireEvent.click(deleteButtons[0]); }); // deleteFavorite called with the correct id await waitFor(() => { expect(mockDelete).toHaveBeenCalledWith('fav-id-1'); }); // The deleted favorite is removed from the list await waitFor(() => { expect(screen.queryByText('GitHub')).not.toBeInTheDocument(); }); }); it('view toggle: switching to grid in edit mode renders grid container', async () => { render( , ); await waitFor(() => { expect(screen.getByText('GitHub')).toBeInTheDocument(); }); // Default list view — list container should be present expect(screen.getByTestId('favorites-list')).toBeInTheDocument(); // Click the 'Kacheln' (grid) toggle button const gridButton = screen.getByRole('button', { name: /favorites\.gridView/i, }); await act(async () => { fireEvent.click(gridButton); }); // Grid container should now be rendered expect(screen.getByTestId('favorites-grid')).toBeInTheDocument(); }); it('letter fallback: link with iconUrl null renders first uppercase letter of title', async () => { render( , ); await waitFor(() => { expect(screen.getByText('Notion')).toBeInTheDocument(); }); // 'Notion' has iconUrl null — the letter fallback span should show 'N' expect(screen.getByTestId('letter-fallback-fav-id-2')).toHaveTextContent('N'); }); });