From a3bb3f2396f69227a218f0f78b6a809e53fa7005 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 1 Jul 2026 10:21:49 +0200 Subject: [PATCH] test(08-03): add failing tests for FavoritesWidget CRUD + view toggle + letter fallback (RED) - fetchFavorites called with instanceId (widgetId scope, Pitfall 3) - covers add/edit/delete, empty state, list/grid toggle, letter fallback - tests fail: favorites-widget.tsx and favorites-api.ts do not exist yet --- .../widgets/favorites-widget.test.tsx | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx diff --git a/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx b/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx new file mode 100644 index 0000000..c8ee1e9 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx @@ -0,0 +1,277 @@ +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'); + + await act(async () => { + fireEvent.change(titleInput, { target: { value: 'New Link' } }); + fireEvent.change(urlInput, { target: { value: 'https://example.com' } }); + }); + + // Submit the form (button with text 'favorites.addButton') + const addButton = screen.getByRole('button', { name: /favorites\.addButton/i }); + await act(async () => { + fireEvent.click(addButton); + }); + + // createFavorite must be called with the widgetId and the entered values + 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, + }); + await act(async () => { + fireEvent.click(editButtons[0]); + }); + + // Change the title in the inline edit form + const editTitleInput = screen.getByDisplayValue('GitHub'); + await act(async () => { + fireEvent.change(editTitleInput, { target: { value: 'GitHub Updated' } }); + }); + + // Click Save + const saveButton = screen.getByRole('button', { + name: /favorites\.saveButton/i, + }); + await act(async () => { + fireEvent.click(saveButton); + }); + + // updateFavorite called with the favorite id and new title + 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, + }); + await act(async () => { + fireEvent.click(deleteButtons[0]); + }); + + // deleteFavorite called with the correct id + 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'); + }); +});