mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
176 lines
6.5 KiB
TypeScript
176 lines
6.5 KiB
TypeScript
import { render, screen, waitFor, act } from '@testing-library/react';
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { ThreadListItem } from '../thread-list-item';
|
|
import { clearRowBadgeCache, normalizeBadges, MAX_ROW_BADGES } from '../plugin-row-badges';
|
|
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
|
|
import { useEmailStore } from '@/stores/email-store';
|
|
import { useUIStore } from '@/stores/ui-store';
|
|
import { groupEmailsByThread } from '@/lib/thread-utils';
|
|
import { renderHooks, clearAllHooks } from '@/lib/plugin-hooks';
|
|
import { refreshRowBadges, resetRowBadgeRefresh } from '@/lib/plugin-sandbox/row-badges';
|
|
import type { Email } from '@/lib/jmap/types';
|
|
|
|
// onEmailListItemRender: plugins add small badges next to a row's subject.
|
|
|
|
vi.mock('@/hooks/use-email-drag', () => ({
|
|
useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
|
|
}));
|
|
|
|
vi.mock('@/stores/auth-store', () => {
|
|
const state = { identities: [], username: null };
|
|
return {
|
|
useAuthStore: (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state),
|
|
};
|
|
});
|
|
|
|
vi.mock('@/stores/account-store', () => {
|
|
const state = { getAccountById: () => undefined };
|
|
return {
|
|
useAccountStore: Object.assign(
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(selector: any) => (selector ? selector(state) : state),
|
|
{ getState: () => state },
|
|
),
|
|
};
|
|
});
|
|
|
|
const makeEmail = (overrides: Partial<Email> = {}): Email => ({
|
|
id: 'email-1',
|
|
threadId: 'thread-1',
|
|
mailboxIds: { inbox: true },
|
|
keywords: { $seen: true },
|
|
size: 1000,
|
|
receivedAt: '2024-01-15T10:00:00Z',
|
|
from: [{ name: 'Alice', email: 'alice@example.com' }],
|
|
subject: 'Test Subject',
|
|
hasAttachment: false,
|
|
...overrides,
|
|
});
|
|
|
|
function row(emails: Email[]) {
|
|
const [thread] = groupEmailsByThread(emails);
|
|
return (
|
|
<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} onEmailSelect={() => {}} />
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
clearAllHooks();
|
|
clearRowBadgeCache();
|
|
resetRowBadgeRefresh();
|
|
useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
|
|
useSettingsStore.setState({
|
|
emailKeywords: [...DEFAULT_KEYWORDS],
|
|
showPreview: false,
|
|
mailLayout: 'split',
|
|
interfaceLayout: 'default',
|
|
});
|
|
useEmailStore.setState({ selectedEmailKeys: new Set<string>(), selectedMailbox: 'inbox' });
|
|
});
|
|
|
|
describe('plugin badges in message-list rows', () => {
|
|
it('shows the badges a plugin returns, with the email as a plain summary', async () => {
|
|
const handler = vi.fn(async (badges: unknown[]) => [
|
|
...badges,
|
|
{ key: 'enc', label: 'Encrypted', color: '#2563eb', title: 'Signed and encrypted' },
|
|
]);
|
|
renderHooks.onEmailListItemRender.register('test', handler);
|
|
|
|
render(row([makeEmail()]));
|
|
|
|
const badge = await screen.findByText('Encrypted');
|
|
expect(badge).toHaveAttribute('title', 'Signed and encrypted');
|
|
expect(badge.style.borderColor).not.toBe('');
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
const [initial, ctx] = handler.mock.calls[0] as unknown as [unknown, { email: Record<string, unknown> }];
|
|
expect(initial).toEqual([]);
|
|
expect(ctx.email).toMatchObject({ id: 'email-1', subject: 'Test Subject', keywords: ['$seen'] });
|
|
// A projection, not the store object.
|
|
expect(ctx.email).not.toHaveProperty('mailboxIds.inbox');
|
|
});
|
|
|
|
it('asks the plugin again when it requests a refresh, at most once a second', async () => {
|
|
let label = 'Open';
|
|
const handler = vi.fn(async () => [{ key: 'task', label }]);
|
|
renderHooks.onEmailListItemRender.register('test', handler);
|
|
render(row([makeEmail()]));
|
|
await screen.findByText('Open');
|
|
|
|
label = 'Done';
|
|
act(() => { expect(refreshRowBadges(10_000)).toBe(true); });
|
|
await screen.findByText('Done');
|
|
expect(refreshRowBadges(10_500)).toBe(false);
|
|
expect(handler).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('shows badges on a conversation row and on the phone layout', async () => {
|
|
useUIStore.setState({ isMobile: true, isTablet: false, isDesktop: false });
|
|
renderHooks.onEmailListItemRender.register('test', async () => [{ key: 'k', label: 'Phone badge' }]);
|
|
|
|
render(row([
|
|
makeEmail({ id: 'a', receivedAt: '2024-01-15T10:00:00Z' }),
|
|
makeEmail({ id: 'b', receivedAt: '2024-01-16T10:00:00Z' }),
|
|
]));
|
|
|
|
expect(await screen.findByText('Phone badge')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders the label as text, never as HTML', async () => {
|
|
renderHooks.onEmailListItemRender.register('test', async () => [
|
|
{ key: 'x', label: '<img src=x onerror=alert(1)>' },
|
|
]);
|
|
|
|
const { container } = render(row([makeEmail()]));
|
|
|
|
expect(await screen.findByText('<img src=x onerror=alert(1)>')).toBeInTheDocument();
|
|
expect(container.querySelector('img[src="x"]')).toBeNull();
|
|
});
|
|
|
|
it('asks plugins once per message and keyword state, not on every render', async () => {
|
|
const handler = vi.fn(async () => [{ key: 'k', label: 'Badge' }]);
|
|
renderHooks.onEmailListItemRender.register('test', handler);
|
|
const email = makeEmail();
|
|
|
|
const view = render(row([email]));
|
|
await screen.findByText('Badge');
|
|
view.rerender(row([{ ...email }]));
|
|
view.unmount();
|
|
render(row([{ ...email }]));
|
|
await screen.findByText('Badge');
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
|
|
// Marking it unread is a new keyword state: ask again.
|
|
render(row([{ ...email, keywords: {} }]));
|
|
await waitFor(() => expect(handler).toHaveBeenCalledTimes(2));
|
|
});
|
|
|
|
it('does nothing without a handler', async () => {
|
|
const transform = vi.spyOn(renderHooks.onEmailListItemRender, 'transform');
|
|
render(row([makeEmail()]));
|
|
await act(async () => {});
|
|
expect(transform).not.toHaveBeenCalled();
|
|
expect(screen.queryByTestId('plugin-row-badges')).toBeNull();
|
|
transform.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('normalizeBadges', () => {
|
|
it(`keeps at most ${MAX_ROW_BADGES} well-formed badges and drops unsafe colours`, () => {
|
|
const out = normalizeBadges([
|
|
{ key: 'a', label: 'A', color: 'url(https://evil.example/x.png)' },
|
|
{ key: 'b', label: '' },
|
|
'nonsense',
|
|
{ key: 'c', label: 'C', color: 'var(--color-warning)' },
|
|
{ key: 'd', label: 'D', color: 'red; background: url(x)' },
|
|
{ key: 'e', label: 'E' },
|
|
{ key: 'f', label: 'F' },
|
|
]);
|
|
expect(out).toEqual([
|
|
{ key: 'a', label: 'A' },
|
|
{ key: 'c', label: 'C', color: 'var(--color-warning)' },
|
|
{ key: 'd', label: 'D' },
|
|
]);
|
|
expect(normalizeBadges(undefined)).toEqual([]);
|
|
});
|
|
});
|