mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
# 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
240 lines
9.5 KiB
TypeScript
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');
|
|
});
|
|
});
|