Files
webmail/components/settings/__tests__/keyword-settings.test.tsx
T
Linus Rath 5a553a315b fix: report refused read changes and re-register push at its limit
Follow-ups to the review of the push, keyword and identity work:

- A send that names no sender, like the List-Unsubscribe mailto: of the
  unified view, fell back to the first identity in server order, which
  on Stalwart 1.0 is a group's. It now takes the login's own identity,
  with addresses compared regardless of case.
- Re-register creates the replacement subscription before it destroys
  the old one, so at Stalwart's limit (15 per account by default) it
  failed with `overQuota`. It now frees the old record's place and
  tries once more.
- On 1.0 the push settings note goes by the emailpush capability alone
  and ignores refusals remembered under 0.16. On 0.16 a group the user
  belongs to is recognised by its identity, so a member without
  calendars, contacts or files keeps the group's junk filter and is no
  longer told the group sends no notifications.
- Plugin imports can no longer set Stalwart 1.0's attachment keywords,
  and `keywords.discover` leaves them out.
- Batch mark-as-read showed messages the server refused as read. It now
  updates only what was written and reports the rest, and thread
  expansion and category-tab moves keep what a partly refused batch
  did write.
- Refused read and star changes from shortcuts, the list, the viewer
  and Pro tabs show "Failed to update email" instead of an unhandled
  rejection. The automatic mark-as-read on opening stays quiet.
- A tag whose id predates the 128-byte limit can have its colour
  changed again.
- The state-regression guard notes that a multi-node 1.0 cluster can
  trip it harmlessly, at the cost of one extra full fetch.
2026-09-25 21:57:11 +02:00

393 lines
14 KiB
TypeScript

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { KeywordSettings } from '../keyword-settings';
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
// Mock SettingsSection to just render children, keeping the real controls
vi.mock('../settings-section', async (importOriginal) => ({
...(await importOriginal<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
describe('KeywordSettings', () => {
beforeEach(() => {
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], nestedTags: false });
});
it('renders all default keywords', () => {
render(<KeywordSettings />);
DEFAULT_KEYWORDS.forEach((kw) => {
expect(screen.getByText(kw.label)).toBeInTheDocument();
});
});
it('shows keyword JMAP id', () => {
render(<KeywordSettings />);
expect(screen.getByText('$label:red')).toBeInTheDocument();
expect(screen.getByText('$label:blue')).toBeInTheDocument();
});
it('renders add keyword button', () => {
render(<KeywordSettings />);
expect(screen.getByText('add_keyword')).toBeInTheDocument();
});
it('shows add form when add button clicked', () => {
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
expect(screen.getByPlaceholderText('label_placeholder')).toBeInTheDocument();
// Cancel and save buttons should appear
expect(screen.getByText('cancel')).toBeInTheDocument();
expect(screen.getByText('add')).toBeInTheDocument();
});
it('adds a new keyword through the form', () => {
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
const input = screen.getByPlaceholderText('label_placeholder');
fireEvent.change(input, { target: { value: 'Important' } });
fireEvent.click(screen.getByText('add'));
const keywords = useSettingsStore.getState().emailKeywords;
expect(keywords).toHaveLength(DEFAULT_KEYWORDS.length + 1);
expect(keywords[keywords.length - 1].label).toBe('Important');
expect(keywords[keywords.length - 1].id).toBe('important');
});
it('prevents adding keyword with duplicate id', () => {
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
const input = screen.getByPlaceholderText('label_placeholder');
fireEvent.change(input, { target: { value: 'Red' } });
// Should show duplicate warning
expect(screen.getByText('id_exists')).toBeInTheDocument();
});
it('cancels add form when cancel clicked', () => {
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
expect(screen.getByPlaceholderText('label_placeholder')).toBeInTheDocument();
fireEvent.click(screen.getByText('cancel'));
expect(screen.queryByPlaceholderText('label_placeholder')).not.toBeInTheDocument();
});
it('deletes keyword when delete button clicked', () => {
render(<KeywordSettings />);
// Find delete buttons (title="delete")
const deleteButtons = screen.getAllByTitle('delete');
expect(deleteButtons.length).toBe(DEFAULT_KEYWORDS.length);
// Delete the first keyword
fireEvent.click(deleteButtons[0]);
expect(useSettingsStore.getState().emailKeywords).toHaveLength(DEFAULT_KEYWORDS.length - 1);
expect(useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red')).toBeUndefined();
});
it('shows edit form when edit button clicked', () => {
render(<KeywordSettings />);
const editButtons = screen.getAllByTitle('edit');
fireEvent.click(editButtons[0]); // edit first keyword (Red)
const input = screen.getByDisplayValue('Red');
expect(input).toBeInTheDocument();
expect(screen.getByText('save')).toBeInTheDocument();
});
it('updates keyword label through edit form', () => {
render(<KeywordSettings />);
const editButtons = screen.getAllByTitle('edit');
fireEvent.click(editButtons[0]); // edit "Red"
const input = screen.getByDisplayValue('Red');
fireEvent.change(input, { target: { value: 'Crimson' } });
fireEvent.click(screen.getByText('save'));
const kw = useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red');
expect(kw?.label).toBe('Crimson');
});
it('normalizes label to id correctly', () => {
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
const input = screen.getByPlaceholderText('label_placeholder');
fireEvent.change(input, { target: { value: 'My Custom Tag!' } });
fireEvent.click(screen.getByText('add'));
const keywords = useSettingsStore.getState().emailKeywords;
const added = keywords[keywords.length - 1];
expect(added.id).toBe('my-custom-tag');
expect(added.label).toBe('My Custom Tag!');
});
it('offers no parent picker while nesting is off', () => {
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
expect(screen.queryByLabelText('parent_field')).not.toBeInTheDocument();
});
it('nests a new tag under the selected parent', () => {
useSettingsStore.setState({
emailKeywords: [{ id: 'work', label: 'Work', color: 'blue' }],
nestedTags: true,
});
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
fireEvent.change(screen.getByLabelText('parent_field'), { target: { value: 'work' } });
fireEvent.change(screen.getByPlaceholderText('label_placeholder'), { target: { value: 'Clients' } });
fireEvent.click(screen.getByText('add'));
const keywords = useSettingsStore.getState().emailKeywords;
expect(keywords[keywords.length - 1]).toMatchObject({ id: 'work/clients', label: 'Clients' });
});
it('shows nested tags by their full path', () => {
useSettingsStore.setState({
emailKeywords: [
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'work/clients', label: 'Clients', color: 'green' },
],
nestedTags: true,
});
render(<KeywordSettings />);
expect(screen.getByText('Work/Clients')).toBeInTheDocument();
expect(screen.getByText('$label:work/clients')).toBeInTheDocument();
});
it('rejects a path that would exceed the 128-byte keyword limit of Stalwart', () => {
// `$label:` + 110 + `/overflowing-name` is 134 bytes: fine by the RFC's
// 255, refused by Stalwart 1.0 and cut short by 0.16.
const deepId = 'a'.repeat(110);
useSettingsStore.setState({
emailKeywords: [{ id: deepId, label: 'Deep', color: 'blue' }],
nestedTags: true,
});
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
fireEvent.change(screen.getByLabelText('parent_field'), { target: { value: deepId } });
fireEvent.change(screen.getByPlaceholderText('label_placeholder'), { target: { value: 'Overflowing name' } });
expect(screen.getByText('too_long')).toBeInTheDocument();
expect(screen.getByText('add').closest('button')).toBeDisabled();
});
it('still saves a new colour for a tag whose id predates the limit', () => {
// 128 characters, 135 bytes as a keyword: created when the limit was the
// RFC's 255.
const parentId = 'a'.repeat(120);
const oldId = `${parentId}/old-tag`;
useSettingsStore.setState({
emailKeywords: [
{ id: parentId, label: 'Deep', color: 'blue' },
{ id: oldId, label: 'Old tag', color: 'blue' },
],
nestedTags: true,
});
render(<KeywordSettings />);
fireEvent.click(screen.getAllByTitle('edit')[1]);
fireEvent.click(screen.getByLabelText('green'));
expect(screen.queryByText('too_long')).not.toBeInTheDocument();
fireEvent.click(screen.getByText('save'));
expect(useSettingsStore.getState().emailKeywords.find((k) => k.id === oldId)?.color).toBe('green');
// Renaming it would make a new id, which has to fit.
fireEvent.click(screen.getAllByTitle('edit')[1]);
fireEvent.change(screen.getByDisplayValue('Old tag'), { target: { value: 'Renamed tag' } });
expect(screen.getByText('too_long')).toBeInTheDocument();
});
it('locks the name and the delete action of a tag that has nested tags', () => {
useSettingsStore.setState({
emailKeywords: [
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'work/clients', label: 'Clients', color: 'green' },
],
nestedTags: true,
});
render(<KeywordSettings />);
expect(screen.getByTitle('has_children_delete')).toBeDisabled();
fireEvent.click(screen.getAllByTitle('edit')[0]);
expect(screen.getByDisplayValue('Work')).toBeDisabled();
expect(screen.getByText('has_children_locked')).toBeInTheDocument();
});
it('defaults every tag to always visible in the sidebar', () => {
render(<KeywordSettings />);
const pickers = screen.getAllByLabelText('visibility_field');
expect(pickers).toHaveLength(DEFAULT_KEYWORDS.length);
pickers.forEach((picker) => expect(picker).toHaveValue('show'));
});
it('stores the visibility chosen for a tag', () => {
render(<KeywordSettings />);
fireEvent.change(screen.getAllByLabelText('visibility_field')[0], { target: { value: 'unread' } });
expect(useSettingsStore.getState().emailKeywords.find((k) => k.id === 'red')?.visibility).toBe('unread');
});
});
// Tag names and colours live only in these settings, so losing them hides tags
// whose keywords are still intact on every message. Scanning finds those
// keywords and offers them back under a name the user never had to remember.
describe('KeywordSettings unrecognized tags', () => {
const discoverKeywords = vi.fn();
beforeEach(() => {
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], nestedTags: false });
discoverKeywords.mockReset();
discoverKeywords.mockResolvedValue({ keywords: {}, scanned: 0, total: 0, complete: true });
useAuthStore.setState({ client: { discoverKeywords, getTagCounts: vi.fn().mockResolvedValue({}) } as never });
useEmailStore.setState({ tagCounts: {} });
});
const scan = async () => {
fireEvent.click(screen.getByText('unrecognized.scan'));
await waitFor(() => expect(discoverKeywords).toHaveBeenCalled());
};
it('offers no scan when there is no connected account', async () => {
useAuthStore.setState({ client: null });
render(<KeywordSettings />);
expect(screen.getByText('unrecognized.scan').closest('button')).toBeDisabled();
});
it('says so when every keyword on the server is already defined', async () => {
discoverKeywords.mockResolvedValue({
keywords: { $seen: 9, '$label:red': 4 },
scanned: 9,
total: 9,
complete: true,
});
render(<KeywordSettings />);
await scan();
expect(await screen.findByText('unrecognized.none')).toBeInTheDocument();
});
it('surfaces an unrecognized keyword with a proposed name and its raw id', async () => {
discoverKeywords.mockResolvedValue({
keywords: { '$label:q3-invoices': 7 },
scanned: 9,
total: 9,
complete: true,
});
render(<KeywordSettings />);
await scan();
expect(await screen.findByDisplayValue('Q3 Invoices')).toBeInTheDocument();
expect(screen.getByText('$label:q3-invoices')).toBeInTheDocument();
});
it('adds the tag under the keyword the messages already carry', async () => {
discoverKeywords.mockResolvedValue({
keywords: { '$label:q3-invoices': 7 },
scanned: 9,
total: 9,
complete: true,
});
render(<KeywordSettings />);
await scan();
await screen.findByDisplayValue('Q3 Invoices');
fireEvent.click(screen.getAllByText('add').slice(-1)[0]);
expect(useSettingsStore.getState().emailKeywords).toContainEqual(
expect.objectContaining({ id: 'q3-invoices', label: 'Q3 Invoices' }),
);
});
it('keeps the id when the proposed name is edited first', async () => {
discoverKeywords.mockResolvedValue({
keywords: { '$label:q3-invoices': 7 },
scanned: 9,
total: 9,
complete: true,
});
render(<KeywordSettings />);
await scan();
fireEvent.change(await screen.findByDisplayValue('Q3 Invoices'), { target: { value: 'Invoices' } });
fireEvent.click(screen.getAllByText('add').slice(-1)[0]);
expect(useSettingsStore.getState().emailKeywords).toContainEqual(
expect.objectContaining({ id: 'q3-invoices', label: 'Invoices' }),
);
});
it('drops a row from the list once its tag is defined', async () => {
discoverKeywords.mockResolvedValue({
keywords: { '$label:q3-invoices': 7 },
scanned: 9,
total: 9,
complete: true,
});
render(<KeywordSettings />);
await scan();
await screen.findByDisplayValue('Q3 Invoices');
fireEvent.click(screen.getAllByText('add').slice(-1)[0]);
// The name field belongs to the unrecognized row alone - the tag itself is
// now in the list above, where it is named by a badge rather than an input.
expect(screen.queryByDisplayValue('Q3 Invoices')).not.toBeInTheDocument();
expect(screen.getByText('unrecognized.none')).toBeInTheDocument();
});
it('adds every found tag at once', async () => {
discoverKeywords.mockResolvedValue({
keywords: { '$label:q3-invoices': 7, '$label:receipts': 3 },
scanned: 9,
total: 9,
complete: true,
});
render(<KeywordSettings />);
await scan();
fireEvent.click(await screen.findByText('unrecognized.add_all'));
const ids = useSettingsStore.getState().emailKeywords.map((k) => k.id);
expect(ids).toContain('q3-invoices');
expect(ids).toContain('receipts');
});
it('warns that a capped scan may have missed tags on older mail', async () => {
discoverKeywords.mockResolvedValue({
keywords: { '$label:q3-invoices': 7 },
scanned: 25000,
total: 90000,
complete: false,
});
render(<KeywordSettings />);
await scan();
expect(await screen.findByText('unrecognized.partial')).toBeInTheDocument();
});
it('reports a failed scan instead of claiming nothing was found', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
discoverKeywords.mockRejectedValue(new Error('network down'));
render(<KeywordSettings />);
await scan();
expect(await screen.findByText('unrecognized.error')).toBeInTheDocument();
expect(screen.queryByText('unrecognized.none')).not.toBeInTheDocument();
});
});