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
This commit is contained in:
@@ -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<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');
|
||||||
|
|
||||||
|
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(
|
||||||
|
<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,
|
||||||
|
});
|
||||||
|
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(
|
||||||
|
<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,
|
||||||
|
});
|
||||||
|
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(
|
||||||
|
<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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user