Files
webmail/components/settings/__tests__/notification-settings.test.tsx
T
Linus Rath c633185cda Merge branch 'main' into feat/stalwart-1.0
# Conflicts:
#	CONTRIBUTING.md
#	LICENSE
#	README.md
#	app/api/calendar-agenda/route.ts
#	app/api/wopi/files/[fileId]/contents/route.ts
#	app/api/wopi/launch/route.ts
#	components/calendar/calendar-app.tsx
#	components/calendar/calendar-day-view.tsx
#	components/calendar/calendar-month-view.tsx
#	components/calendar/calendar-week-view.tsx
#	components/calendar/event-modal.tsx
#	components/contacts/contact-detail.tsx
#	components/email/email-composer.tsx
#	components/email/email-list.tsx
#	components/files/file-browser.tsx
#	components/files/files-app.tsx
#	components/files/folder-tree-sidebar.tsx
#	components/filters/filter-rule-modal.tsx
#	components/mail/mail-app.tsx
#	hooks/use-mailbox-drop.ts
#	integration/run-tests.sh
#	lib/__tests__/calendar-agenda-route.test.ts
#	lib/__tests__/jmap-draft-lifecycle.test.ts
#	lib/__tests__/web-push.test.ts
#	lib/account-state-manager.ts
#	lib/jmap/client-interface.ts
#	lib/jmap/client.ts
#	lib/jmap/types.ts
#	lib/message-list-order.ts
#	lib/sieve/generator.ts
#	lib/sieve/parser.ts
#	lib/telemetry/payload.ts
#	lib/thread-utils.ts
#	lib/unified-mailbox.ts
#	lib/web-push.ts
#	lib/wopi/token.ts
#	locales/ar/common.json
#	locales/ca/common.json
#	locales/cs/common.json
#	locales/da/common.json
#	locales/de/common.json
#	locales/en/common.json
#	locales/es/common.json
#	locales/fa/common.json
#	locales/fr/common.json
#	locales/he/common.json
#	locales/hu/common.json
#	locales/it/common.json
#	locales/ja/common.json
#	locales/ko/common.json
#	locales/lv/common.json
#	locales/mn/common.json
#	locales/nb/common.json
#	locales/nl/common.json
#	locales/pl/common.json
#	locales/pt/common.json
#	locales/ro/common.json
#	locales/ru/common.json
#	locales/sk/common.json
#	locales/tr/common.json
#	locales/uk/common.json
#	locales/zh-TW/common.json
#	locales/zh/common.json
#	scripts/lite/lib.mjs
#	stores/__tests__/calendar-store-synthetic-ids.test.ts
#	stores/__tests__/email-store-shared-folder-keywords.test.ts
#	stores/__tests__/email-store-unified-actions.test.ts
#	stores/__tests__/file-store.test.ts
#	stores/email-store.ts
#	stores/file-store.ts
#	stores/message-list-tabs-store.ts
2026-10-12 08:35:08 +02:00

240 lines
9.5 KiB
TypeScript

import { fireEvent, render, waitFor } from '@testing-library/react';
import { act } from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NotificationSettings } from '../notification-settings';
import { useSettingsStore } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store';
import { usePolicyStore } from '@/stores/policy-store';
import { useEmailStore } from '@/stores/email-store';
import { useIdentityStore } from '@/stores/identity-store';
import { jmapSetError } from '@/lib/jmap/jmap-error';
import type { Mailbox } from '@/lib/jmap/types';
import * as webPush from '@/lib/web-push';
// next-intl's useTranslations -> identity so labels are their keys.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
// Keep the section wrapper out of the way.
vi.mock('../settings-section', async (importOriginal) => ({
...(await importOriginal<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@/lib/web-push', () => ({
WebPushUnsupportedError: class WebPushUnsupportedError extends Error {},
isWebPushSupported: () => true,
isWebPushEnabled: vi.fn(async () => true),
enableWebPush: vi.fn(async () => ({ subscriptionId: 'sub-1' })),
disableWebPush: vi.fn(async () => undefined),
listPushDevices: vi.fn(async () => []),
mailAccountsWithoutPush: vi.fn(() => []),
revokePushDevice: vi.fn(async () => undefined),
serverSupportsEmailPush: vi.fn(() => true),
}));
const fakeClient = { getAccountId: () => 'acct-1' } as unknown as ReturnType<
typeof useAuthStore.getState
>['client'];
describe('NotificationSettings - pushNotifyInboxOnly', () => {
beforeEach(() => {
vi.clearAllMocks();
useSettingsStore.setState({ emailNotificationsEnabled: true, pushNotifyInboxOnly: false });
useAuthStore.setState({ client: fakeClient, username: 'me@example.com' });
usePolicyStore.setState({
...usePolicyStore.getState(),
policy: {},
isSettingLocked: () => false,
isSettingHidden: () => false,
} as never);
});
it('shows the toggle when the server supports emailPush filters', async () => {
const { findByText } = render(<NotificationSettings />);
await findByText('email.inbox_only');
});
it('hides the toggle when the server cannot filter pushes', async () => {
(webPush.serverSupportsEmailPush as ReturnType<typeof vi.fn>).mockReturnValue(false);
const { findByText, queryByText } = render(<NotificationSettings />);
await findByText('email.sound');
expect(queryByText('email.inbox_only')).toBeNull();
(webPush.serverSupportsEmailPush as ReturnType<typeof vi.fn>).mockReturnValue(true);
});
it('does not re-sync push on mount', async () => {
render(<NotificationSettings />);
// Let the "is push enabled?" effect settle.
await waitFor(() => expect(webPush.isWebPushEnabled).toHaveBeenCalled());
expect(webPush.enableWebPush).not.toHaveBeenCalled();
});
it('re-runs enableWebPush with the new flag when the toggle flips while push is on', async () => {
const { findByText } = render(<NotificationSettings />);
await findByText('push.status_active');
act(() => {
useSettingsStore.getState().updateSetting('pushNotifyInboxOnly', true);
});
await waitFor(() => expect(webPush.enableWebPush).toHaveBeenCalledTimes(1));
expect(webPush.enableWebPush).toHaveBeenCalledWith(
expect.objectContaining({ client: fakeClient, inboxOnly: true }),
);
// forceRecreate stays off - a filter PATCH, not a destroy/recreate.
expect(
(webPush.enableWebPush as ReturnType<typeof vi.fn>).mock.calls[0][0],
).not.toHaveProperty('forceRecreate', true);
});
it('does not get stuck on "busy" after the re-sync resolves', async () => {
const { findByText, queryByText } = render(<NotificationSettings />);
await findByText('push.status_active');
const devicesLoadsBefore = (webPush.listPushDevices as ReturnType<typeof vi.fn>).mock.calls.length;
act(() => {
useSettingsStore.getState().updateSetting('pushNotifyInboxOnly', true);
});
// enableWebPush resolves -> status returns to active, device list refreshes.
await findByText('push.status_active');
expect(queryByText('push.status_busy')).toBeNull();
await waitFor(() =>
expect(
(webPush.listPushDevices as ReturnType<typeof vi.fn>).mock.calls.length,
).toBeGreaterThan(devicesLoadsBefore),
);
});
it('checks push again when the user comes back to the tab', async () => {
Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true });
const { findByText } = render(<NotificationSettings />);
await findByText('email.inbox_only');
const check = webPush.isWebPushEnabled as ReturnType<typeof vi.fn>;
await waitFor(() => expect(check).toHaveBeenCalled());
const before = check.mock.calls.length;
// Push may have been turned off elsewhere (site settings, another tab) meanwhile.
await act(async () => { window.dispatchEvent(new Event('focus')); });
await waitFor(() => expect(check.mock.calls.length).toBeGreaterThan(before));
});
it('does not re-sync when push is disabled on this device', async () => {
(webPush.isWebPushEnabled as ReturnType<typeof vi.fn>).mockResolvedValueOnce(false);
render(<NotificationSettings />);
await waitFor(() => expect(webPush.isWebPushEnabled).toHaveBeenCalled());
act(() => {
useSettingsStore.getState().updateSetting('pushNotifyInboxOnly', true);
});
// Give any effect a chance to fire.
await Promise.resolve();
expect(webPush.enableWebPush).not.toHaveBeenCalled();
});
});
describe('NotificationSettings - mailboxes shared by ACL', () => {
const mailbox = (id: string, accountId: string, isShared: boolean) =>
({ id, originalId: id, name: id, accountId, isShared }) as unknown as Mailbox;
beforeEach(() => {
vi.clearAllMocks();
useSettingsStore.setState({ emailNotificationsEnabled: true, pushNotifyInboxOnly: false });
useAuthStore.setState({ client: fakeClient, username: 'me@example.com' });
usePolicyStore.setState({
...usePolicyStore.getState(),
policy: {},
isSettingLocked: () => false,
isSettingHidden: () => false,
} as never);
useEmailStore.setState({
mailboxes: [
mailbox('inbox', 'acct-1', false),
mailbox('acct-group:inbox', 'acct-group', true),
mailbox('acct-acl:projects', 'acct-acl', true),
],
});
// Stalwart 0.16 gives a group's members an identity with its address.
useIdentityStore.setState({
identities: [
{ id: 'i-own', name: 'Me', email: 'me@example.com', mayDelete: false },
{ id: 'i-group', name: 'Team', email: 'team@example.com', mayDelete: true },
],
});
});
it('says that mailboxes shared by other users send no background notifications', async () => {
(webPush.mailAccountsWithoutPush as ReturnType<typeof vi.fn>).mockReturnValue(['acct-acl']);
const { findByText } = render(<NotificationSettings />);
await findByText('push.shared_mailboxes_no_push');
// The identities tell the user's groups apart on Stalwart 0.16.
expect(webPush.mailAccountsWithoutPush).toHaveBeenCalledWith(
fakeClient,
['acct-group', 'acct-acl'],
['me@example.com', 'team@example.com'],
);
});
it('says nothing when every shared mailbox is covered', async () => {
(webPush.mailAccountsWithoutPush as ReturnType<typeof vi.fn>).mockReturnValue([]);
const { findByText, queryByText } = render(<NotificationSettings />);
await findByText('push.status_active');
expect(queryByText('push.shared_mailboxes_no_push')).toBeNull();
});
});
describe('NotificationSettings - push errors', () => {
beforeEach(() => {
vi.clearAllMocks();
useSettingsStore.setState({ emailNotificationsEnabled: true, pushNotifyInboxOnly: false });
useAuthStore.setState({ client: fakeClient, username: 'me@example.com' });
usePolicyStore.setState({
...usePolicyStore.getState(),
policy: {},
isSettingLocked: () => false,
isSettingHidden: () => false,
} as never);
(webPush.isWebPushEnabled as ReturnType<typeof vi.fn>).mockResolvedValue(false);
});
async function enableAndFail(error: unknown) {
(webPush.enableWebPush as ReturnType<typeof vi.fn>).mockRejectedValueOnce(error);
const view = render(<NotificationSettings />);
await view.findByText('push.status_inactive');
fireEvent.click(view.getByRole('switch', { name: 'push.enable' }));
return (await view.findByRole('alert')).textContent;
}
it('explains a forbidden refusal instead of showing the raw server error', async () => {
const text = await enableAndFail(jmapSetError('PushSubscription/set create failed: forbidden', {
type: 'forbidden',
description: 'No access to one of the accounts in the emailPush map.',
}));
expect(text).toBe('push.error_forbidden');
});
it('explains the subscription limit on overQuota', async () => {
const text = await enableAndFail(jmapSetError('PushSubscription/set create failed', {
type: 'overQuota',
description: 'There are too many subscriptions, please delete some before adding a new one.',
}));
expect(text).toBe('push.error_over_quota');
});
it('keeps the message of any other failure', async () => {
const text = await enableAndFail(new Error('Relay register failed: 502'));
expect(text).toBe('Relay register failed: 502');
});
});