From 4657e504946e157dd58bdd409b196d3343bd7966 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 1 Jul 2026 10:46:50 +0200 Subject: [PATCH] test(08-04): add failing tests for LinkWidget single-link contract (RED) - fetchFavorites called with instanceId on mount - Link renders as anchor with target="_blank" rel="noreferrer" (T-08-12) - Empty + edit mode shows add form; createFavorite called on submit - Single-link enforcement: add form hidden when link exists (D-06) - Edit mode: updateFavorite called with id and new title - View toggle: list default, tile container on gridView click - Letter fallback: iconUrl null shows first uppercase letter --- .../dashboard/widgets/link-widget.test.tsx | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 apps/web/src/components/dashboard/widgets/link-widget.test.tsx diff --git a/apps/web/src/components/dashboard/widgets/link-widget.test.tsx b/apps/web/src/components/dashboard/widgets/link-widget.test.tsx new file mode 100644 index 0000000..59748cc --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/link-widget.test.tsx @@ -0,0 +1,231 @@ +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 (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, +} from '@/lib/favorites-api'; + +// Must import AFTER mocks +import { LinkWidget } from './link-widget'; + +// Typed mock helpers +const mockFetch = fetchFavorites as ReturnType; +const mockCreate = createFavorite as ReturnType; +const mockUpdate = updateFavorite as ReturnType; + +const SINGLE_LINK = { + id: 'link-id-1', + widgetId: 'link-1', + title: 'GitHub', + url: 'https://github.com', + iconUrl: 'https://github.com/favicon.ico', + position: 0, +}; + +describe('LinkWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockResolvedValue([SINGLE_LINK]); + mockCreate.mockResolvedValue({ + id: 'link-id-new', + widgetId: 'link-1', + title: 'New Site', + url: 'https://example.com', + iconUrl: null, + position: 0, + }); + mockUpdate.mockResolvedValue({ ...SINGLE_LINK, title: 'GitHub Updated' }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('calls fetchFavorites with instanceId on mount and renders the link as an anchor with target="_blank" rel="noreferrer"', async () => { + render( + , + ); + + // fetchFavorites must be called with the instanceId (widgetId scope — Pitfall 3) + expect(mockFetch).toHaveBeenCalledWith('link-1'); + + // After async resolution, link title should be visible + await waitFor(() => { + expect(screen.getByText('GitHub')).toBeInTheDocument(); + }); + + // Link must open in new tab with noreferrer (T-08-12) + const anchor = screen.getByText('GitHub').closest('a'); + expect(anchor).toHaveAttribute('target', '_blank'); + expect(anchor).toHaveAttribute('rel', 'noreferrer'); + }); + + it('shows empty message in non-edit mode when no link exists', async () => { + mockFetch.mockResolvedValue([]); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('link.empty')).toBeInTheDocument(); + }); + }); + + it('shows add form in edit mode when no link exists; submitting calls createFavorite with widgetId, title, url', async () => { + mockFetch.mockResolvedValue([]); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByPlaceholderText('link.addTitle')).toBeInTheDocument(); + }); + + const titleInput = screen.getByPlaceholderText('link.addTitle'); + const urlInput = screen.getByPlaceholderText('link.addUrl'); + + act(() => { + fireEvent.change(titleInput, { target: { value: 'New Site' } }); + fireEvent.change(urlInput, { target: { value: 'https://example.com' } }); + }); + + const addButton = screen.getByRole('button', { name: /link\.addButton/i }); + act(() => { + fireEvent.click(addButton); + }); + + await waitFor(() => { + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + widgetId: 'link-1', + title: 'New Site', + url: 'https://example.com', + }), + ); + }); + + // New link title should appear after creation + await waitFor(() => { + expect(screen.getByText('New Site')).toBeInTheDocument(); + }); + }); + + it('single-link enforcement: add form is hidden when a link already exists', async () => { + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('GitHub')).toBeInTheDocument(); + }); + + // Add form must NOT be present — single-link constraint (D-06) + expect(screen.queryByPlaceholderText('link.addTitle')).not.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 (aria-label is the translation key) + const editButton = screen.getByRole('button', { name: /link\.editButton/i }); + act(() => { + fireEvent.click(editButton); + }); + + // Inline edit form appears with current title pre-filled + await waitFor(() => { + expect(screen.getByDisplayValue('GitHub')).toBeInTheDocument(); + }); + + const editTitleInput = screen.getByDisplayValue('GitHub'); + act(() => { + fireEvent.change(editTitleInput, { target: { value: 'GitHub Updated' } }); + }); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /link\.saveButton/i })).toBeInTheDocument(); + }); + + const saveButton = screen.getByRole('button', { name: /link\.saveButton/i }); + act(() => { + fireEvent.click(saveButton); + }); + + // updateFavorite called with the link id and new title + await waitFor(() => { + expect(mockUpdate).toHaveBeenCalledWith( + 'link-id-1', + expect.objectContaining({ title: 'GitHub Updated' }), + ); + }); + }); + + it('view toggle: list is default; switching to tile in edit mode renders tile container', async () => { + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('GitHub')).toBeInTheDocument(); + }); + + // Default list view — list container should be present + expect(screen.getByTestId('link-list')).toBeInTheDocument(); + + // Click the tile (grid) toggle button + const tileButton = screen.getByRole('button', { name: /link\.gridView/i }); + await act(async () => { + fireEvent.click(tileButton); + }); + + // Tile container should now be rendered + expect(screen.getByTestId('link-tile')).toBeInTheDocument(); + }); + + it('letter fallback: link with iconUrl null renders first uppercase letter of title', async () => { + mockFetch.mockResolvedValue([{ ...SINGLE_LINK, iconUrl: null, title: 'Notion' }]); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('Notion')).toBeInTheDocument(); + }); + + // Letter fallback span should show 'N' (first letter of 'Notion') + expect(screen.getByTestId('link-letter-fallback')).toHaveTextContent('N'); + }); +});