- Add frontend unit tests with Vitest for components, hooks, pages, and utilities - Add backend tests for webhooks, notifications, middleware, and edge cases - Add ForgotPassword, NotFound, and ResetPassword pages - Add migration for orphaned staff resources conversion - Add coverage directory to gitignore (generated reports) - Various bug fixes and improvements from previous work 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
454 lines
13 KiB
TypeScript
454 lines
13 KiB
TypeScript
/**
|
|
* Unit tests for Portal component
|
|
*
|
|
* Tests the Portal component which uses ReactDOM.createPortal to render
|
|
* children outside the parent DOM hierarchy. This is useful for modals,
|
|
* tooltips, and other UI elements that need to escape parent stacking contexts.
|
|
*/
|
|
|
|
import { describe, it, expect, afterEach } from 'vitest';
|
|
import { render, screen, cleanup } from '@testing-library/react';
|
|
import Portal from '../Portal';
|
|
|
|
describe('Portal', () => {
|
|
afterEach(() => {
|
|
// Clean up any rendered components
|
|
cleanup();
|
|
});
|
|
|
|
describe('Basic Rendering', () => {
|
|
it('should render children', () => {
|
|
render(
|
|
<Portal>
|
|
<div data-testid="portal-content">Portal Content</div>
|
|
</Portal>
|
|
);
|
|
|
|
expect(screen.getByTestId('portal-content')).toBeInTheDocument();
|
|
expect(screen.getByText('Portal Content')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render text content', () => {
|
|
render(<Portal>Simple text content</Portal>);
|
|
|
|
expect(screen.getByText('Simple text content')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render complex JSX children', () => {
|
|
render(
|
|
<Portal>
|
|
<div>
|
|
<h1>Title</h1>
|
|
<p>Description</p>
|
|
<button>Click me</button>
|
|
</div>
|
|
</Portal>
|
|
);
|
|
|
|
expect(screen.getByRole('heading', { name: 'Title' })).toBeInTheDocument();
|
|
expect(screen.getByText('Description')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Portal Behavior', () => {
|
|
it('should render content to document.body', () => {
|
|
const { container } = render(
|
|
<div id="root">
|
|
<Portal>
|
|
<div data-testid="portal-content">Portal Content</div>
|
|
</Portal>
|
|
</div>
|
|
);
|
|
|
|
const portalContent = screen.getByTestId('portal-content');
|
|
|
|
// Portal content should NOT be inside the container
|
|
expect(container.contains(portalContent)).toBe(false);
|
|
|
|
// Portal content SHOULD be inside document.body
|
|
expect(document.body.contains(portalContent)).toBe(true);
|
|
});
|
|
|
|
it('should escape parent DOM hierarchy', () => {
|
|
const { container } = render(
|
|
<div id="parent" style={{ position: 'relative', zIndex: 1 }}>
|
|
<div id="child">
|
|
<Portal>
|
|
<div data-testid="portal-content">Escaped Content</div>
|
|
</Portal>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const portalContent = screen.getByTestId('portal-content');
|
|
const parent = container.querySelector('#parent');
|
|
|
|
// Portal content should not be inside parent
|
|
expect(parent?.contains(portalContent)).toBe(false);
|
|
|
|
// Portal content should be direct child of body
|
|
expect(portalContent.parentElement).toBe(document.body);
|
|
});
|
|
});
|
|
|
|
describe('Multiple Children', () => {
|
|
it('should render multiple children', () => {
|
|
render(
|
|
<Portal>
|
|
<div data-testid="child-1">First child</div>
|
|
<div data-testid="child-2">Second child</div>
|
|
<div data-testid="child-3">Third child</div>
|
|
</Portal>
|
|
);
|
|
|
|
expect(screen.getByTestId('child-1')).toBeInTheDocument();
|
|
expect(screen.getByTestId('child-2')).toBeInTheDocument();
|
|
expect(screen.getByTestId('child-3')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render an array of children', () => {
|
|
const items = ['Item 1', 'Item 2', 'Item 3'];
|
|
|
|
render(
|
|
<Portal>
|
|
{items.map((item, index) => (
|
|
<div key={index} data-testid={`item-${index}`}>
|
|
{item}
|
|
</div>
|
|
))}
|
|
</Portal>
|
|
);
|
|
|
|
items.forEach((item, index) => {
|
|
expect(screen.getByTestId(`item-${index}`)).toBeInTheDocument();
|
|
expect(screen.getByText(item)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should render nested components', () => {
|
|
const NestedComponent = () => (
|
|
<div data-testid="nested">
|
|
<span>Nested Component</span>
|
|
</div>
|
|
);
|
|
|
|
render(
|
|
<Portal>
|
|
<NestedComponent />
|
|
<div>Other content</div>
|
|
</Portal>
|
|
);
|
|
|
|
expect(screen.getByTestId('nested')).toBeInTheDocument();
|
|
expect(screen.getByText('Nested Component')).toBeInTheDocument();
|
|
expect(screen.getByText('Other content')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Mounting Behavior', () => {
|
|
it('should not render before component is mounted', () => {
|
|
// This test verifies the internal mounting state
|
|
const { rerender } = render(
|
|
<Portal>
|
|
<div data-testid="portal-content">Content</div>
|
|
</Portal>
|
|
);
|
|
|
|
// After initial render, content should be present
|
|
expect(screen.getByTestId('portal-content')).toBeInTheDocument();
|
|
|
|
// Re-render should still show content
|
|
rerender(
|
|
<Portal>
|
|
<div data-testid="portal-content">Updated Content</div>
|
|
</Portal>
|
|
);
|
|
|
|
expect(screen.getByText('Updated Content')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Multiple Portals', () => {
|
|
it('should support multiple portal instances', () => {
|
|
render(
|
|
<div>
|
|
<Portal>
|
|
<div data-testid="portal-1">Portal 1</div>
|
|
</Portal>
|
|
<Portal>
|
|
<div data-testid="portal-2">Portal 2</div>
|
|
</Portal>
|
|
<Portal>
|
|
<div data-testid="portal-3">Portal 3</div>
|
|
</Portal>
|
|
</div>
|
|
);
|
|
|
|
expect(screen.getByTestId('portal-1')).toBeInTheDocument();
|
|
expect(screen.getByTestId('portal-2')).toBeInTheDocument();
|
|
expect(screen.getByTestId('portal-3')).toBeInTheDocument();
|
|
|
|
// All portals should be in document.body
|
|
expect(document.body.contains(screen.getByTestId('portal-1'))).toBe(true);
|
|
expect(document.body.contains(screen.getByTestId('portal-2'))).toBe(true);
|
|
expect(document.body.contains(screen.getByTestId('portal-3'))).toBe(true);
|
|
});
|
|
|
|
it('should keep portals separate from each other', () => {
|
|
render(
|
|
<div>
|
|
<Portal>
|
|
<div data-testid="portal-1">
|
|
<span data-testid="content-1">Content 1</span>
|
|
</div>
|
|
</Portal>
|
|
<Portal>
|
|
<div data-testid="portal-2">
|
|
<span data-testid="content-2">Content 2</span>
|
|
</div>
|
|
</Portal>
|
|
</div>
|
|
);
|
|
|
|
const portal1 = screen.getByTestId('portal-1');
|
|
const portal2 = screen.getByTestId('portal-2');
|
|
const content1 = screen.getByTestId('content-1');
|
|
const content2 = screen.getByTestId('content-2');
|
|
|
|
// Each portal should contain only its own content
|
|
expect(portal1.contains(content1)).toBe(true);
|
|
expect(portal1.contains(content2)).toBe(false);
|
|
expect(portal2.contains(content2)).toBe(true);
|
|
expect(portal2.contains(content1)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Cleanup', () => {
|
|
it('should remove content from body when unmounted', () => {
|
|
const { unmount } = render(
|
|
<Portal>
|
|
<div data-testid="portal-content">Temporary Content</div>
|
|
</Portal>
|
|
);
|
|
|
|
// Content should exist initially
|
|
expect(screen.getByTestId('portal-content')).toBeInTheDocument();
|
|
|
|
// Unmount the component
|
|
unmount();
|
|
|
|
// Content should be removed from DOM
|
|
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should clean up multiple portals on unmount', () => {
|
|
const { unmount } = render(
|
|
<div>
|
|
<Portal>
|
|
<div data-testid="portal-1">Portal 1</div>
|
|
</Portal>
|
|
<Portal>
|
|
<div data-testid="portal-2">Portal 2</div>
|
|
</Portal>
|
|
</div>
|
|
);
|
|
|
|
expect(screen.getByTestId('portal-1')).toBeInTheDocument();
|
|
expect(screen.getByTestId('portal-2')).toBeInTheDocument();
|
|
|
|
unmount();
|
|
|
|
expect(screen.queryByTestId('portal-1')).not.toBeInTheDocument();
|
|
expect(screen.queryByTestId('portal-2')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Re-rendering', () => {
|
|
it('should update content on re-render', () => {
|
|
const { rerender } = render(
|
|
<Portal>
|
|
<div data-testid="portal-content">Initial Content</div>
|
|
</Portal>
|
|
);
|
|
|
|
expect(screen.getByText('Initial Content')).toBeInTheDocument();
|
|
|
|
rerender(
|
|
<Portal>
|
|
<div data-testid="portal-content">Updated Content</div>
|
|
</Portal>
|
|
);
|
|
|
|
expect(screen.getByText('Updated Content')).toBeInTheDocument();
|
|
expect(screen.queryByText('Initial Content')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should handle prop changes', () => {
|
|
const TestComponent = ({ message }: { message: string }) => (
|
|
<Portal>
|
|
<div data-testid="message">{message}</div>
|
|
</Portal>
|
|
);
|
|
|
|
const { rerender } = render(<TestComponent message="First message" />);
|
|
|
|
expect(screen.getByText('First message')).toBeInTheDocument();
|
|
|
|
rerender(<TestComponent message="Second message" />);
|
|
|
|
expect(screen.getByText('Second message')).toBeInTheDocument();
|
|
expect(screen.queryByText('First message')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Edge Cases', () => {
|
|
it('should handle empty children', () => {
|
|
render(<Portal>{null}</Portal>);
|
|
|
|
// Should not throw error
|
|
expect(document.body).toBeInTheDocument();
|
|
});
|
|
|
|
it('should handle undefined children', () => {
|
|
render(<Portal>{undefined}</Portal>);
|
|
|
|
// Should not throw error
|
|
expect(document.body).toBeInTheDocument();
|
|
});
|
|
|
|
it('should handle boolean children', () => {
|
|
render(
|
|
<Portal>
|
|
{false && <div>Should not render</div>}
|
|
{true && <div data-testid="should-render">Should render</div>}
|
|
</Portal>
|
|
);
|
|
|
|
expect(screen.queryByText('Should not render')).not.toBeInTheDocument();
|
|
expect(screen.getByTestId('should-render')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should handle conditional rendering', () => {
|
|
const { rerender } = render(
|
|
<Portal>
|
|
{false && <div data-testid="conditional">Conditional Content</div>}
|
|
</Portal>
|
|
);
|
|
|
|
expect(screen.queryByTestId('conditional')).not.toBeInTheDocument();
|
|
|
|
rerender(
|
|
<Portal>
|
|
{true && <div data-testid="conditional">Conditional Content</div>}
|
|
</Portal>
|
|
);
|
|
|
|
expect(screen.getByTestId('conditional')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Integration with Parent Components', () => {
|
|
it('should work inside modals', () => {
|
|
const Modal = ({ children }: { children: React.ReactNode }) => (
|
|
<div className="modal" data-testid="modal">
|
|
<Portal>{children}</Portal>
|
|
</div>
|
|
);
|
|
|
|
const { container } = render(
|
|
<Modal>
|
|
<div data-testid="modal-content">Modal Content</div>
|
|
</Modal>
|
|
);
|
|
|
|
const modalContent = screen.getByTestId('modal-content');
|
|
const modal = container.querySelector('[data-testid="modal"]');
|
|
|
|
// Modal content should not be inside modal container
|
|
expect(modal?.contains(modalContent)).toBe(false);
|
|
|
|
// Modal content should be in document.body
|
|
expect(document.body.contains(modalContent)).toBe(true);
|
|
});
|
|
|
|
it('should preserve event handlers', () => {
|
|
let clicked = false;
|
|
const handleClick = () => {
|
|
clicked = true;
|
|
};
|
|
|
|
render(
|
|
<Portal>
|
|
<button data-testid="button" onClick={handleClick}>
|
|
Click me
|
|
</button>
|
|
</Portal>
|
|
);
|
|
|
|
const button = screen.getByTestId('button');
|
|
button.click();
|
|
|
|
expect(clicked).toBe(true);
|
|
});
|
|
|
|
it('should preserve CSS classes and styles', () => {
|
|
render(
|
|
<Portal>
|
|
<div
|
|
data-testid="styled-content"
|
|
className="custom-class"
|
|
style={{ color: 'red', fontSize: '16px' }}
|
|
>
|
|
Styled Content
|
|
</div>
|
|
</Portal>
|
|
);
|
|
|
|
const styledContent = screen.getByTestId('styled-content');
|
|
|
|
expect(styledContent).toHaveClass('custom-class');
|
|
// Check styles individually - color may be normalized to rgb()
|
|
expect(styledContent.style.color).toBeTruthy();
|
|
expect(styledContent.style.fontSize).toBe('16px');
|
|
});
|
|
});
|
|
|
|
describe('Accessibility', () => {
|
|
it('should maintain ARIA attributes', () => {
|
|
render(
|
|
<Portal>
|
|
<div
|
|
data-testid="aria-content"
|
|
role="dialog"
|
|
aria-label="Test Dialog"
|
|
aria-describedby="description"
|
|
>
|
|
<div id="description">Dialog description</div>
|
|
</div>
|
|
</Portal>
|
|
);
|
|
|
|
const content = screen.getByTestId('aria-content');
|
|
|
|
expect(content).toHaveAttribute('role', 'dialog');
|
|
expect(content).toHaveAttribute('aria-label', 'Test Dialog');
|
|
expect(content).toHaveAttribute('aria-describedby', 'description');
|
|
});
|
|
|
|
it('should support semantic HTML inside portal', () => {
|
|
render(
|
|
<Portal>
|
|
<dialog open data-testid="dialog">
|
|
<h2>Dialog Title</h2>
|
|
<p>Dialog content</p>
|
|
</dialog>
|
|
</Portal>
|
|
);
|
|
|
|
expect(screen.getByTestId('dialog')).toBeInTheDocument();
|
|
expect(screen.getByRole('heading', { name: 'Dialog Title' })).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|