Files
tessera-ctl/apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx
T
schalli cc6f5ae893 feat(08-03): FavoritesWidget frontend + API client + page.tsx wiring (GREEN)
- favorites-api.ts: FavoriteLink type + fetchFavorites/createFavorite/updateFavorite/deleteFavorite
  all use credentials: include and API_URL/favorites
- favorites-widget.tsx: list/grid view, inline add/edit/delete in edit mode,
  icon + letter fallback, rel=noreferrer + target=_blank, no dangerouslySetInnerHTML (T-08-07)
- useEffect deps fixed to [instanceId] only — excludes t() to prevent re-fetch on each render
- page.tsx: wireFavoritesWidget(FavoritesWidget) wired
- Full test suite: 81/81 pass (17 test files)
- Web TypeScript: clean
2026-07-01 10:34:11 +02:00

291 lines
8.0 KiB
TypeScript

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<typeof vi.fn>;
const mockCreate = createFavorite as ReturnType<typeof vi.fn>;
const mockUpdate = updateFavorite as ReturnType<typeof vi.fn>;
const mockDelete = deleteFavorite as ReturnType<typeof vi.fn>;
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(
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={false}
/>,
);
// 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(
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={false}
/>,
);
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(
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={true}
/>,
);
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(
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={true}
/>,
);
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(
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={true}
/>,
);
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(
<FavoritesWidget
instanceId="fav-1"
config={{ viewMode: 'list' }}
isEditMode={true}
/>,
);
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(
<FavoritesWidget
instanceId="fav-1"
config={{}}
isEditMode={false}
/>,
);
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');
});
});