b15c74632b
- API: ausdrueckliche iconUrl wird nur auf Form (http/https, <= 2048) geprueft und auch gespeichert, wenn der Server sie nicht abrufen kann; keine 422 mehr - Erkennung: Seite mit Fehlerstatus, aber HTML mit <link rel=icon>, liefert diesen Verweis (docuvita); og:image einer Fehlerseite zaehlt nicht - Kachel: Proxy -> iconUrl direkt im Browser (no-referrer, nur http/https) -> Origin-Favicon -> Buchstabe - Meldung iconUrlUnreachable (de/en) entfernt, Hinweis zum Vorrang angepasst Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
950 lines
32 KiB
TypeScript
950 lines
32 KiB
TypeScript
import { act, fireEvent, render, screen, waitFor, within } 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 functions as controllable vi.fn() spies
|
|
// (reorderFavorites hinzugefuegt, 260917-jdd — ohne diesen Eintrag
|
|
// importiert das Widget undefined und der Klick auf einen Sortierpfeil
|
|
// wirft TypeError.)
|
|
//
|
|
// 260923-lrr: die Fabrik ist jetzt ASYNCHRON und uebernimmt per
|
|
// `vi.importActual` die ECHTE `FavoriteRequestError`-Klasse und
|
|
// `FAVORITE_ICON_MAX_BYTES` — ein `instanceof FavoriteRequestError` im
|
|
// Widget muss gegen dieselbe Klasse pruefen, die die Tests werfen, sonst
|
|
// faellt `favoriteErrorKey` immer auf `favorites.error` zurueck.
|
|
// `uploadFavoriteIcon`/`removeFavoriteIcon` kommen als `vi.fn()` dazu.
|
|
vi.mock('@/lib/favorites-api', async () => {
|
|
const actual = await vi.importActual<typeof import('@/lib/favorites-api')>('@/lib/favorites-api');
|
|
return {
|
|
FavoriteRequestError: actual.FavoriteRequestError,
|
|
FAVORITE_ICON_MAX_BYTES: actual.FAVORITE_ICON_MAX_BYTES,
|
|
fetchFavorites: vi.fn(),
|
|
createFavorite: vi.fn(),
|
|
updateFavorite: vi.fn(),
|
|
deleteFavorite: vi.fn(),
|
|
reorderFavorites: vi.fn(),
|
|
uploadFavoriteIcon: vi.fn(),
|
|
removeFavoriteIcon: vi.fn(),
|
|
};
|
|
});
|
|
|
|
// Mock dashboard-api for updateWidgetConfig (view mode persistence)
|
|
vi.mock('@/lib/dashboard-api', () => ({
|
|
updateWidgetConfig: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
import {
|
|
FavoriteRequestError,
|
|
fetchFavorites,
|
|
createFavorite,
|
|
updateFavorite,
|
|
deleteFavorite,
|
|
reorderFavorites,
|
|
uploadFavoriteIcon,
|
|
removeFavoriteIcon,
|
|
} from '@/lib/favorites-api';
|
|
import { updateWidgetConfig } from '@/lib/dashboard-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 mockReorder = reorderFavorites as ReturnType<typeof vi.fn>;
|
|
const mockUploadIcon = uploadFavoriteIcon as ReturnType<typeof vi.fn>;
|
|
const mockRemoveIcon = removeFavoriteIcon as ReturnType<typeof vi.fn>;
|
|
const mockUpdateWidgetConfig = updateWidgetConfig 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');
|
|
});
|
|
|
|
// quick-260916-iex: optionaler Titel
|
|
describe('optionaler Titel (quick-260916-iex)', () => {
|
|
it('Ansicht ohne (leeren/ungueltigen) Titel zeigt keine Kopfzeile', async () => {
|
|
const r1 = render(
|
|
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
|
);
|
|
await waitFor(() => {
|
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
|
});
|
|
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
|
expect(
|
|
screen.queryByPlaceholderText('favorites.titlePlaceholder'),
|
|
).not.toBeInTheDocument();
|
|
r1.unmount();
|
|
|
|
const r2 = render(
|
|
<FavoritesWidget
|
|
instanceId="fav-1"
|
|
config={{ title: ' ' }}
|
|
isEditMode={false}
|
|
/>,
|
|
);
|
|
await waitFor(() => {
|
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
|
});
|
|
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
|
r2.unmount();
|
|
|
|
render(
|
|
<FavoritesWidget
|
|
instanceId="fav-1"
|
|
config={{ title: 42 }}
|
|
isEditMode={false}
|
|
/>,
|
|
);
|
|
await waitFor(() => {
|
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
|
});
|
|
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('Ansicht mit Titel zeigt eine H2-Kopfzeile', async () => {
|
|
render(
|
|
<FavoritesWidget
|
|
instanceId="fav-1"
|
|
config={{ title: 'Werkzeuge' }}
|
|
isEditMode={false}
|
|
/>,
|
|
);
|
|
await waitFor(() => {
|
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
|
});
|
|
const heading = screen.getByRole('heading', { name: 'Werkzeuge' });
|
|
expect(heading.tagName).toBe('H2');
|
|
expect(heading.className).toContain('text-sm');
|
|
expect(heading.className).toContain('font-semibold');
|
|
expect(
|
|
screen.queryByPlaceholderText('favorites.titlePlaceholder'),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('Bearbeitungsmodus ohne Titel zeigt ein Titelfeld statt einer Kopfzeile', async () => {
|
|
render(
|
|
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
|
|
);
|
|
await waitFor(() => {
|
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
|
});
|
|
|
|
const titleField = screen.getByPlaceholderText(
|
|
'favorites.titlePlaceholder',
|
|
) as HTMLInputElement;
|
|
expect(titleField.value).toBe('');
|
|
expect(titleField.className).toContain('widgetNoDrag');
|
|
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
|
|
|
expect(
|
|
screen.getByRole('button', { name: /favorites\.listView/i }),
|
|
).toBeInTheDocument();
|
|
expect(
|
|
screen.getByRole('button', { name: /favorites\.gridView/i }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it('entprellt das Speichern des Titels um 1500 ms', async () => {
|
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
|
try {
|
|
render(
|
|
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
|
|
);
|
|
await act(async () => {});
|
|
|
|
const titleField = screen.getByPlaceholderText(
|
|
'favorites.titlePlaceholder',
|
|
);
|
|
|
|
for (const value of ['W', 'We', 'Werkzeuge']) {
|
|
await act(async () => {
|
|
fireEvent.change(titleField, { target: { value } });
|
|
vi.advanceTimersByTime(200);
|
|
});
|
|
}
|
|
expect(mockUpdateWidgetConfig).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(1500);
|
|
});
|
|
expect(mockUpdateWidgetConfig).toHaveBeenCalledTimes(1);
|
|
expect(mockUpdateWidgetConfig).toHaveBeenCalledWith('fav-1', {
|
|
title: 'Werkzeuge',
|
|
});
|
|
|
|
await act(async () => {
|
|
fireEvent.change(titleField, { target: { value: '' } });
|
|
vi.advanceTimersByTime(1500);
|
|
});
|
|
expect(mockUpdateWidgetConfig).toHaveBeenCalledWith('fav-1', {
|
|
title: '',
|
|
});
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
// 260917-jdd: Browser-Ersatzweg (Proxy -> Direktbild -> Buchstabe) und
|
|
// Sortierpfeile im Bearbeitungsmodus.
|
|
describe('Ersatzbild und Sortierung (quick-260917-jdd)', () => {
|
|
it('Ersatzbild bei iconUrl null: Direktbild statt Proxy-Bild, referrerpolicy no-referrer', async () => {
|
|
render(
|
|
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Notion')).toBeInTheDocument();
|
|
});
|
|
|
|
const directImg = screen.getByTestId('icon-direct-fav-id-2') as HTMLImageElement;
|
|
expect(directImg.src).toBe('https://notion.so/favicon.ico');
|
|
expect(directImg.getAttribute('referrerpolicy')).toBe('no-referrer');
|
|
expect(screen.queryByTestId('icon-proxy-fav-id-2')).not.toBeInTheDocument();
|
|
expect(screen.getByTestId('letter-fallback-fav-id-2')).toHaveTextContent('N');
|
|
});
|
|
|
|
it('Kette Proxy -> direkt -> Buchstabe', async () => {
|
|
render(
|
|
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
|
});
|
|
|
|
const proxyImg = screen.getByTestId('icon-proxy-fav-id-1') as HTMLImageElement;
|
|
// 260923-lrr: die Proxy-Adresse traegt jetzt ?v=<iconVersion> (Cache-Bust).
|
|
expect(proxyImg.getAttribute('src')).toBe('/api-proxy/favorites/fav-id-1/icon?v=0');
|
|
expect(screen.queryByTestId('icon-direct-fav-id-1')).not.toBeInTheDocument();
|
|
|
|
act(() => {
|
|
fireEvent.error(proxyImg);
|
|
});
|
|
|
|
expect(screen.queryByTestId('icon-proxy-fav-id-1')).not.toBeInTheDocument();
|
|
const directImg = screen.getByTestId('icon-direct-fav-id-1') as HTMLImageElement;
|
|
expect(directImg.src).toBe('https://github.com/favicon.ico');
|
|
|
|
act(() => {
|
|
fireEvent.error(directImg);
|
|
});
|
|
|
|
expect(screen.queryByTestId('icon-proxy-fav-id-1')).not.toBeInTheDocument();
|
|
expect(screen.queryByTestId('icon-direct-fav-id-1')).not.toBeInTheDocument();
|
|
expect(screen.getByTestId('letter-fallback-fav-id-1')).toHaveTextContent('G');
|
|
});
|
|
|
|
it('ausdrueckliche Symbol-Adresse (260929-lh3): Proxy -> Browser laedt iconUrl direkt (no-referrer) -> Origin-Favicon -> Buchstabe', async () => {
|
|
mockFetch.mockResolvedValue([
|
|
{
|
|
id: 'fav-id-9',
|
|
widgetId: 'fav-1',
|
|
title: 'Docuvita',
|
|
url: 'https://docuvita.ctl.local/server/services/web/',
|
|
iconUrl: 'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico',
|
|
position: 0,
|
|
},
|
|
]);
|
|
|
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Docuvita')).toBeInTheDocument();
|
|
});
|
|
|
|
act(() => {
|
|
fireEvent.error(screen.getByTestId('icon-proxy-fav-id-9'));
|
|
});
|
|
|
|
const direct1 = screen.getByTestId('icon-direct-fav-id-9') as HTMLImageElement;
|
|
expect(direct1.src).toBe(
|
|
'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico',
|
|
);
|
|
expect(direct1.getAttribute('referrerpolicy')).toBe('no-referrer');
|
|
|
|
act(() => {
|
|
fireEvent.error(direct1);
|
|
});
|
|
|
|
const direct2 = screen.getByTestId('icon-direct-fav-id-9') as HTMLImageElement;
|
|
expect(direct2.src).toBe('https://docuvita.ctl.local/favicon.ico');
|
|
|
|
act(() => {
|
|
fireEvent.error(direct2);
|
|
});
|
|
|
|
expect(screen.queryByTestId('icon-direct-fav-id-9')).not.toBeInTheDocument();
|
|
expect(screen.getByTestId('letter-fallback-fav-id-9')).toHaveTextContent('D');
|
|
});
|
|
|
|
it('iconUrl mit Nicht-http-Schema wird NIE direkt geladen (javascript:/data:)', async () => {
|
|
mockFetch.mockResolvedValue([
|
|
{
|
|
id: 'fav-id-8',
|
|
widgetId: 'fav-1',
|
|
title: 'Boese',
|
|
url: 'https://boese.example',
|
|
iconUrl: 'javascript:alert(1)',
|
|
position: 0,
|
|
},
|
|
]);
|
|
|
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Boese')).toBeInTheDocument();
|
|
});
|
|
|
|
act(() => {
|
|
fireEvent.error(screen.getByTestId('icon-proxy-fav-id-8'));
|
|
});
|
|
|
|
const direct = screen.getByTestId('icon-direct-fav-id-8') as HTMLImageElement;
|
|
expect(direct.src).toBe('https://boese.example/favicon.ico');
|
|
});
|
|
|
|
it('kein Direktbild bei Nicht-http-URL', async () => {
|
|
mockFetch.mockResolvedValue([
|
|
{ id: 'fav-id-3', widgetId: 'fav-1', title: 'Ablage', url: 'ftp://files.example', iconUrl: null, position: 0 },
|
|
]);
|
|
|
|
render(
|
|
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Ablage')).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.queryByTestId('icon-direct-fav-id-3')).not.toBeInTheDocument();
|
|
expect(screen.queryByTestId('icon-proxy-fav-id-3')).not.toBeInTheDocument();
|
|
expect(screen.getByTestId('letter-fallback-fav-id-3')).toHaveTextContent('A');
|
|
});
|
|
|
|
it('Pfeile: Zustand (erster/letzter deaktiviert) und Klick loest reorderFavorites aus', async () => {
|
|
mockReorder.mockResolvedValue([
|
|
{ ...BASE_FAVORITES[1], position: 0 },
|
|
{ ...BASE_FAVORITES[0], position: 1 },
|
|
]);
|
|
|
|
render(
|
|
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
|
});
|
|
|
|
const up = screen.getAllByRole('button', { name: 'favorites.moveUpButton' });
|
|
const down = screen.getAllByRole('button', { name: 'favorites.moveDownButton' });
|
|
|
|
expect(up[0]).toBeDisabled();
|
|
expect(down[0]).not.toBeDisabled();
|
|
expect(up[1]).not.toBeDisabled();
|
|
expect(down[1]).toBeDisabled();
|
|
|
|
act(() => {
|
|
fireEvent.click(down[0]);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockReorder).toHaveBeenCalledWith('fav-1', ['fav-id-2', 'fav-id-1']);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
const titles = within(screen.getByTestId('favorites-list'))
|
|
.getAllByRole('link')
|
|
.map((a) => a.querySelector('.truncate')?.textContent);
|
|
expect(titles).toEqual(['Notion', 'GitHub']);
|
|
});
|
|
});
|
|
|
|
it('Fehler beim Sortieren -> Neuladen, Fehlermeldung, alte Reihenfolge', async () => {
|
|
mockReorder.mockRejectedValue(new Error('boom'));
|
|
|
|
render(
|
|
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
|
});
|
|
|
|
const down = screen.getAllByRole('button', { name: 'favorites.moveDownButton' });
|
|
|
|
act(() => {
|
|
fireEvent.click(down[0]);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('favorites.error')).toBeInTheDocument();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
const titles = within(screen.getByTestId('favorites-list'))
|
|
.getAllByRole('link')
|
|
.map((a) => a.querySelector('.truncate')?.textContent);
|
|
expect(titles).toEqual(['GitHub', 'Notion']);
|
|
});
|
|
});
|
|
});
|
|
|
|
it('quick-260921-gof (Befund 14): erneutes Zeichnen mit gleicher instanceId loest KEINEN weiteren Abruf aus — die Uebersetzungs-Attrappe liefert bei jedem Durchlauf eine frische Funktion, eine Rueckkehr von `t` in die Abhaengigkeitsliste faellt hier sofort auf', async () => {
|
|
const { rerender } = render(
|
|
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
|
);
|
|
|
|
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
|
|
|
|
rerender(
|
|
<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />,
|
|
);
|
|
|
|
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
|
|
});
|
|
|
|
// 260923-lrr: eigenes Symbol hochladen, Zwischenspeicher nach Aenderung
|
|
// erneuern (versionierte Symbol-Adresse, Datei-Auswahl beim Bearbeiten
|
|
// und Hinzufuegen, Entfernen-Knopf, Meldungen im Formular).
|
|
describe('Eigenes Symbol (quick-260923-lrr)', () => {
|
|
function openEditForGitHub() {
|
|
const editButtons = screen.getAllByRole('button', { name: /favorites\.editButton/i });
|
|
act(() => {
|
|
fireEvent.click(editButtons[0]);
|
|
});
|
|
}
|
|
|
|
it('Proxy-Bild traegt ?v=<iconVersion>; Zeile ohne iconVersion -> ?v=0', async () => {
|
|
mockFetch.mockResolvedValue([{ ...BASE_FAVORITES[0], iconVersion: 3 }, BASE_FAVORITES[1]]);
|
|
|
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
|
});
|
|
|
|
const proxyImg = screen.getByTestId('icon-proxy-fav-id-1') as HTMLImageElement;
|
|
expect(proxyImg.getAttribute('src')).toBe('/api-proxy/favorites/fav-id-1/icon?v=3');
|
|
});
|
|
|
|
it('Zeile nur mit uploadedIconMime (iconUrl null) -> Proxy-Bild statt Direktbild', async () => {
|
|
mockFetch.mockResolvedValue([
|
|
{
|
|
id: 'fav-id-4',
|
|
widgetId: 'fav-1',
|
|
title: 'Eigenes',
|
|
url: 'https://eigenes.invalid',
|
|
iconUrl: null,
|
|
uploadedIconMime: 'image/png',
|
|
iconVersion: 1,
|
|
position: 0,
|
|
},
|
|
]);
|
|
|
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Eigenes')).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.getByTestId('icon-proxy-fav-id-4')).toBeInTheDocument();
|
|
expect(screen.queryByTestId('icon-direct-fav-id-4')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('Logo-Adress-Feld zeigt den Platzhalter favorites.iconUrlPlaceholder (kein fest verdrahteter Text mehr)', async () => {
|
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
|
});
|
|
|
|
openEditForGitHub();
|
|
|
|
expect(screen.getByPlaceholderText('favorites.iconUrlPlaceholder')).toBeInTheDocument();
|
|
});
|
|
|
|
it('Speichern mit neuer Logo-Adresse, updateFavorite liefert iconVersion 1 -> src des Proxy-Bildes endet danach auf ?v=1', async () => {
|
|
mockUpdate.mockResolvedValue({
|
|
...BASE_FAVORITES[0],
|
|
iconUrl: 'https://neu.invalid/icon.png',
|
|
iconVersion: 1,
|
|
});
|
|
|
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
|
});
|
|
|
|
openEditForGitHub();
|
|
|
|
const iconUrlField = screen.getByPlaceholderText('favorites.iconUrlPlaceholder');
|
|
act(() => {
|
|
fireEvent.change(iconUrlField, { target: { value: 'https://neu.invalid/icon.png' } });
|
|
});
|
|
|
|
const saveButton = screen.getByRole('button', { name: /favorites\.saveButton/i });
|
|
act(() => {
|
|
fireEvent.click(saveButton);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdate).toHaveBeenCalledWith(
|
|
'fav-id-1',
|
|
expect.objectContaining({ iconUrl: 'https://neu.invalid/icon.png' }),
|
|
);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
const proxyImg = screen.getByTestId('icon-proxy-fav-id-1') as HTMLImageElement;
|
|
expect(proxyImg.getAttribute('src')).toBe('/api-proxy/favorites/fav-id-1/icon?v=1');
|
|
});
|
|
});
|
|
|
|
it('Datei im Bearbeitungsformular waehlen, Speichern -> updateFavorite, danach uploadFavoriteIcon(\'fav-id-1\', Datei); Formular schliesst', async () => {
|
|
mockUpdate.mockResolvedValue({ ...BASE_FAVORITES[0], iconVersion: 1 });
|
|
mockUploadIcon.mockResolvedValue({
|
|
...BASE_FAVORITES[0],
|
|
uploadedIconMime: 'image/png',
|
|
iconVersion: 2,
|
|
});
|
|
|
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
|
});
|
|
|
|
openEditForGitHub();
|
|
|
|
const fileField = screen.getByTestId('favorite-icon-upload-fav-id-1');
|
|
const file = new File([new Uint8Array([1, 2, 3])], 'icon.png', { type: 'image/png' });
|
|
act(() => {
|
|
fireEvent.change(fileField, { target: { files: [file] } });
|
|
});
|
|
|
|
const saveButton = screen.getByRole('button', { name: /favorites\.saveButton/i });
|
|
act(() => {
|
|
fireEvent.click(saveButton);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdate).toHaveBeenCalledWith('fav-id-1', expect.objectContaining({ title: 'GitHub' }));
|
|
});
|
|
await waitFor(() => {
|
|
expect(mockUploadIcon).toHaveBeenCalledWith('fav-id-1', file);
|
|
});
|
|
await waitFor(() => {
|
|
expect(screen.queryByTestId('favorite-icon-upload-fav-id-1')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('updateFavorite wirft (z. B. 400 ungueltige Adresse) -> allgemeine Meldung favorites.error im Formular (role alert), Formular bleibt offen, uploadFavoriteIcon NICHT aufgerufen', async () => {
|
|
mockUpdate.mockRejectedValue(new Error('Failed to update favorite'));
|
|
|
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
|
});
|
|
|
|
openEditForGitHub();
|
|
|
|
const fileField = screen.getByTestId('favorite-icon-upload-fav-id-1');
|
|
const file = new File([new Uint8Array([1])], 'icon.png', { type: 'image/png' });
|
|
act(() => {
|
|
fireEvent.change(fileField, { target: { files: [file] } });
|
|
});
|
|
|
|
const saveButton = screen.getByRole('button', { name: /favorites\.saveButton/i });
|
|
act(() => {
|
|
fireEvent.click(saveButton);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('alert')).toHaveTextContent('favorites.error');
|
|
});
|
|
expect(mockUploadIcon).not.toHaveBeenCalled();
|
|
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
|
|
});
|
|
|
|
it('Upload wirft FavoriteRequestError(iconTooLarge) -> favorites.iconTooLarge im Formular, Formular bleibt offen', async () => {
|
|
mockUpdate.mockResolvedValue({ ...BASE_FAVORITES[0] });
|
|
mockUploadIcon.mockRejectedValue(new FavoriteRequestError('iconTooLarge'));
|
|
|
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
|
});
|
|
|
|
openEditForGitHub();
|
|
|
|
const fileField = screen.getByTestId('favorite-icon-upload-fav-id-1');
|
|
const file = new File([new Uint8Array([1])], 'icon.png', { type: 'image/png' });
|
|
act(() => {
|
|
fireEvent.change(fileField, { target: { files: [file] } });
|
|
});
|
|
|
|
const saveButton = screen.getByRole('button', { name: /favorites\.saveButton/i });
|
|
act(() => {
|
|
fireEvent.click(saveButton);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('alert')).toHaveTextContent('favorites.iconTooLarge');
|
|
});
|
|
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
|
|
});
|
|
|
|
it('Knopf favorites.iconRemoveButton nur bei gesetztem uploadedIconMime; Klick -> removeFavoriteIcon(\'fav-id-1\'), danach verschwindet der Knopf, Formular bleibt offen', async () => {
|
|
mockFetch.mockResolvedValue([
|
|
{ ...BASE_FAVORITES[0], uploadedIconMime: 'image/png', iconVersion: 1 },
|
|
BASE_FAVORITES[1],
|
|
]);
|
|
mockRemoveIcon.mockResolvedValue({ ...BASE_FAVORITES[0], uploadedIconMime: null, iconVersion: 2 });
|
|
|
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
|
});
|
|
|
|
openEditForGitHub();
|
|
|
|
const removeButton = screen.getByRole('button', { name: 'favorites.iconRemoveButton' });
|
|
act(() => {
|
|
fireEvent.click(removeButton);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockRemoveIcon).toHaveBeenCalledWith('fav-id-1');
|
|
});
|
|
await waitFor(() => {
|
|
expect(screen.queryByRole('button', { name: 'favorites.iconRemoveButton' })).not.toBeInTheDocument();
|
|
});
|
|
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
|
|
});
|
|
|
|
it('kein Entfernen-Knopf ohne hochgeladenes Symbol', async () => {
|
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
|
});
|
|
|
|
openEditForGitHub();
|
|
|
|
expect(screen.queryByRole('button', { name: 'favorites.iconRemoveButton' })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('Hinzufuegen mit gewaehlter Datei -> createFavorite, danach uploadFavoriteIcon(created.id, Datei)', async () => {
|
|
mockFetch.mockResolvedValue([]);
|
|
mockUploadIcon.mockResolvedValue({
|
|
id: 'fav-id-new',
|
|
widgetId: 'fav-1',
|
|
title: 'New Link',
|
|
url: 'https://example.com',
|
|
iconUrl: null,
|
|
uploadedIconMime: 'image/png',
|
|
iconVersion: 1,
|
|
position: 2,
|
|
});
|
|
|
|
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('favorites.empty')).toBeInTheDocument();
|
|
});
|
|
|
|
const titleInput = screen.getByPlaceholderText('favorites.addTitle');
|
|
const urlInput = screen.getByPlaceholderText('favorites.addUrl');
|
|
const fileInput = screen.getByTestId('favorite-add-icon-upload');
|
|
const file = new File([new Uint8Array([1, 2, 3])], 'icon.png', { type: 'image/png' });
|
|
|
|
act(() => {
|
|
fireEvent.change(titleInput, { target: { value: 'New Link' } });
|
|
fireEvent.change(urlInput, { target: { value: 'https://example.com' } });
|
|
fireEvent.change(fileInput, { target: { files: [file] } });
|
|
});
|
|
|
|
const addButton = screen.getByRole('button', { name: /favorites\.addButton/i });
|
|
act(() => {
|
|
fireEvent.click(addButton);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockCreate).toHaveBeenCalledWith(
|
|
expect.objectContaining({ widgetId: 'fav-1', title: 'New Link', url: 'https://example.com' }),
|
|
);
|
|
});
|
|
await waitFor(() => {
|
|
expect(mockUploadIcon).toHaveBeenCalledWith('fav-id-new', file);
|
|
});
|
|
await waitFor(() => {
|
|
expect(screen.getByText('New Link')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
});
|