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
This commit is contained in:
@@ -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<typeof vi.fn>;
|
||||
const mockCreate = createFavorite as ReturnType<typeof vi.fn>;
|
||||
const mockUpdate = updateFavorite as ReturnType<typeof vi.fn>;
|
||||
|
||||
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(
|
||||
<LinkWidget instanceId="link-1" config={{}} isEditMode={false} />,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<LinkWidget instanceId="link-1" config={{}} isEditMode={false} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<LinkWidget instanceId="link-1" config={{}} isEditMode={true} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<LinkWidget instanceId="link-1" config={{}} isEditMode={true} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<LinkWidget instanceId="link-1" config={{}} isEditMode={true} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<LinkWidget
|
||||
instanceId="link-1"
|
||||
config={{ viewMode: 'list' }}
|
||||
isEditMode={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<LinkWidget instanceId="link-1" config={{}} isEditMode={false} />,
|
||||
);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user