test(05-02): add failing tests for search and note widgets

- SearchWidget: provider selection, window.open with encoded query, Enter key trigger
- NoteWidget: debounced autosave, rapid typing collapse, AbortController usage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 11:30:45 +02:00
parent afc555b18d
commit 97c13809f5
2 changed files with 333 additions and 0 deletions
@@ -0,0 +1,202 @@
import { act, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock next-intl
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => {
const map: Record<string, string> = {
'note.defaultTitle': 'Notiz',
'note.autosaveError': 'Speichern fehlgeschlagen',
};
return map[key] ?? key;
},
}));
// Mock @uiw/react-md-editor — provide a minimal textarea-based substitute
vi.mock('@uiw/react-md-editor', () => {
const MDEditor = ({
value,
onChange,
'data-testid': testId,
}: {
value?: string;
onChange?: (val?: string) => void;
'data-testid'?: string;
}) => (
<textarea
data-testid={testId ?? 'md-editor'}
value={value}
onChange={(e) => onChange?.(e.target.value)}
/>
);
MDEditor.displayName = 'MDEditor';
return {
__esModule: true,
default: MDEditor,
commands: {
bold: {},
italic: {},
strikethrough: {},
divider: {},
unorderedListCommand: {},
checkedListCommand: {},
link: {},
code: {},
},
};
});
// Must import after mocks
import { NoteWidget } from './note-widget';
describe('NoteWidget', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.useFakeTimers();
fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({}),
} as Response);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('renders the editor with default title', () => {
render(
<NoteWidget instanceId="note-1" config={{}} isEditMode={false} />,
);
expect(screen.getByDisplayValue('Notiz')).toBeInTheDocument();
expect(screen.getByTestId('md-editor')).toBeInTheDocument();
});
it('schedules a debounced PATCH after typing', async () => {
render(
<NoteWidget instanceId="note-1" config={{}} isEditMode={false} />,
);
const editor = screen.getByTestId('md-editor');
// Type into editor
await act(async () => {
editor.dispatchEvent(
new Event('change', { bubbles: true }),
);
// Simulate onChange via the mock
const event = new Event('input', { bubbles: true });
Object.defineProperty(event, 'target', { value: { value: '# Hello' } });
// Use fireEvent approach
});
// Directly trigger onChange via the textarea
await act(async () => {
const textarea = screen.getByTestId('md-editor') as HTMLTextAreaElement;
// Change value triggers onChange in our mock
Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
'value',
)?.set?.call(textarea, '# Hello');
textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.dispatchEvent(new Event('change', { bubbles: true }));
});
// Before debounce fires, fetch should NOT have been called
expect(fetchSpy).not.toHaveBeenCalled();
// Advance past debounce window
await act(async () => {
vi.advanceTimersByTime(1500);
});
expect(fetchSpy).toHaveBeenCalledWith(
expect.stringContaining('/api/dashboard/widgets/note-1/config'),
expect.objectContaining({
method: 'PATCH',
}),
);
});
it('collapses rapid typing to a single save', async () => {
render(
<NoteWidget instanceId="note-1" config={{}} isEditMode={false} />,
);
const textarea = screen.getByTestId('md-editor') as HTMLTextAreaElement;
// Simulate rapid typing — three changes in quick succession
for (const char of ['H', 'He', 'Hel']) {
await act(async () => {
Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
'value',
)?.set?.call(textarea, char);
textarea.dispatchEvent(new Event('change', { bubbles: true }));
});
// Small delay between keystrokes (less than debounce)
await act(async () => {
vi.advanceTimersByTime(200);
});
}
// Still within debounce window of last keystroke
expect(fetchSpy).not.toHaveBeenCalled();
// Advance past the debounce window from the last keystroke
await act(async () => {
vi.advanceTimersByTime(1500);
});
// Should have only saved once (the last value)
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('uses AbortController to cancel in-flight requests', async () => {
const abortSpy = vi.spyOn(AbortController.prototype, 'abort');
render(
<NoteWidget instanceId="note-1" config={{}} isEditMode={false} />,
);
const textarea = screen.getByTestId('md-editor') as HTMLTextAreaElement;
// First change -> debounce -> save starts
await act(async () => {
Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
'value',
)?.set?.call(textarea, 'first');
textarea.dispatchEvent(new Event('change', { bubbles: true }));
});
await act(async () => {
vi.advanceTimersByTime(1500);
});
// First save was called
expect(fetchSpy).toHaveBeenCalledTimes(1);
// Second change -> debounce -> should abort the first
await act(async () => {
Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
'value',
)?.set?.call(textarea, 'second');
textarea.dispatchEvent(new Event('change', { bubbles: true }));
});
await act(async () => {
vi.advanceTimersByTime(1500);
});
// Abort should have been called (cancelling the previous request's controller)
expect(abortSpy).toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalledTimes(2);
abortSpy.mockRestore();
});
});