Files
webmail/lib/__tests__/deep-links.test.ts
T
Linus Rath 64b39c504a fix: open the inbox instead of the last folder when the app starts
The boot snapshot restored the last plain folder that was open, and
unified views are never written to it. A session that touched Trash and
then moved to the unified inbox therefore opened Trash on every visit to
`/`. A plain visit now starts in the account's own inbox and drops rows
cached for another folder; reloading a `/mail/folder/...` link still
continues in that folder.
2026-10-02 16:31:44 +02:00

370 lines
14 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from 'vitest';
import {
buildFolderRef,
resolveFolderRef,
isFolderLinkOpen,
pathNamesMailFolder,
buildMailPath,
parseMailPath,
buildCalendarPath,
parseCalendarPath,
formatLinkDate,
parseLinkDate,
buildContactsPath,
parseContactsPath,
buildFilesPath,
parseFilesPath,
buildSettingsPath,
parseSettingsPath,
matchSurface,
SCHEDULED_MAILBOX_ID,
} from '@/lib/deep-links';
import {
UNIFIED_MAILBOX_IDS,
CROSS_VIEW_IDS,
type Mailbox,
} from '@/lib/jmap/types';
function mailbox(partial: Partial<Mailbox> & { id: string }): Mailbox {
return {
name: partial.id,
totalEmails: 0,
unreadEmails: 0,
...partial,
} as Mailbox;
}
const MAILBOXES: Mailbox[] = [
mailbox({ id: 'a', role: 'inbox', name: 'Inbox' }),
mailbox({ id: 'b', role: 'sent', name: 'Sent' }),
mailbox({ id: 'c', name: 'Projects/2026' }),
mailbox({ id: 'shared-a', role: 'inbox', name: 'Team Inbox', isShared: true, accountId: 'team' }),
];
// `appPath` bakes NEXT_PUBLIC_LOCALE_PREFIX / NEXT_PUBLIC_BASE_PATH at module
// load (mirroring how next.config.ts and i18n/routing.ts read them at build
// time), so prefix-mode cases have to re-import under a fresh env.
async function loadLinks(env: { localePrefix?: string; basePath?: string }) {
vi.resetModules();
if (env.localePrefix === undefined) delete process.env.NEXT_PUBLIC_LOCALE_PREFIX;
else process.env.NEXT_PUBLIC_LOCALE_PREFIX = env.localePrefix;
if (env.basePath === undefined) delete process.env.NEXT_PUBLIC_BASE_PATH;
else process.env.NEXT_PUBLIC_BASE_PATH = env.basePath;
return import('@/lib/deep-links');
}
afterEach(() => {
delete process.env.NEXT_PUBLIC_LOCALE_PREFIX;
delete process.env.NEXT_PUBLIC_BASE_PATH;
vi.resetModules();
});
describe('appPath — locale prefix and mount prefix', () => {
it('omits the locale in the default "never" mode', async () => {
const { appPath } = await loadLinks({});
expect(appPath('/mail/message/abc', 'de')).toBe('/mail/message/abc');
});
it('always prefixes the locale in "always" mode', async () => {
const { appPath } = await loadLinks({ localePrefix: 'always' });
expect(appPath('/mail/message/abc', 'de')).toBe('/de/mail/message/abc');
expect(appPath('/', 'de')).toBe('/de');
});
it('prefixes only non-default locales in "as-needed" mode', async () => {
const { appPath } = await loadLinks({ localePrefix: 'as-needed' });
expect(appPath('/calendar', 'en')).toBe('/calendar');
expect(appPath('/calendar', 'fr')).toBe('/fr/calendar');
});
it('applies the mount prefix on subpath deployments', async () => {
const { appPath } = await loadLinks({ localePrefix: 'always', basePath: '/webmail' });
expect(appPath('/mail/thread/t1', 'de')).toBe('/webmail/de/mail/thread/t1');
});
it('builds an absolute URL for copy-link', async () => {
const { appUrl } = await loadLinks({});
expect(appUrl('/mail/message/abc')).toBe(`${window.location.origin}/mail/message/abc`);
});
});
describe('mail folder references', () => {
it('uses the role name for the account\'s own well-known folders', () => {
expect(buildFolderRef('a', MAILBOXES)).toBe('inbox');
expect(buildFolderRef('b', MAILBOXES)).toBe('sent');
});
it('keeps the raw id for custom folders', () => {
expect(buildFolderRef('c', MAILBOXES)).toBe('c');
});
it('keeps the raw id for shared folders so they don\'t claim the role alias', () => {
expect(buildFolderRef('shared-a', MAILBOXES)).toBe('shared-a');
expect(resolveFolderRef('inbox', MAILBOXES)).toBe('a');
});
it('aliases the virtual unified, cross-account and scheduled views', () => {
expect(buildFolderRef(UNIFIED_MAILBOX_IDS.inbox)).toBe('unified-inbox');
expect(buildFolderRef(CROSS_VIEW_IDS.unread)).toBe('cross-unread');
expect(buildFolderRef(SCHEDULED_MAILBOX_ID)).toBe('scheduled');
expect(resolveFolderRef('unified-inbox')).toBe(UNIFIED_MAILBOX_IDS.inbox);
expect(resolveFolderRef('cross-unread')).toBe(CROSS_VIEW_IDS.unread);
expect(resolveFolderRef('scheduled')).toBe(SCHEDULED_MAILBOX_ID);
});
it('prefers an exact id match over an alias', () => {
const odd = [...MAILBOXES, mailbox({ id: 'inbox', name: 'A folder literally called inbox' })];
expect(resolveFolderRef('inbox', odd)).toBe('inbox');
});
it('returns null for a role that this account does not have', () => {
expect(resolveFolderRef('archive', MAILBOXES)).toBeNull();
});
});
describe('isFolderLinkOpen', () => {
const plainInbox = {
selectedMailbox: 'a',
isUnifiedView: false,
isScheduledView: false,
selectedKeyword: null,
hasSearch: false,
};
it('treats the folder the list already shows as open (page reload)', () => {
expect(isFolderLinkOpen(resolveFolderRef('inbox', MAILBOXES)!, plainInbox)).toBe(true);
});
it('does not treat another folder as open', () => {
expect(isFolderLinkOpen('b', plainInbox)).toBe(false);
});
it('does not treat the folder as open while a tag, search or special view covers it', () => {
expect(isFolderLinkOpen('a', { ...plainInbox, selectedKeyword: 'work' })).toBe(false);
expect(isFolderLinkOpen('a', { ...plainInbox, hasSearch: true })).toBe(false);
expect(isFolderLinkOpen('a', { ...plainInbox, isUnifiedView: true })).toBe(false);
expect(isFolderLinkOpen('a', { ...plainInbox, isScheduledView: true })).toBe(false);
});
it('never treats a virtual view as open - the boot fetch does not load them', () => {
for (const id of [UNIFIED_MAILBOX_IDS.inbox, CROSS_VIEW_IDS.unread, SCHEDULED_MAILBOX_ID]) {
expect(isFolderLinkOpen(id, { ...plainInbox, selectedMailbox: id })).toBe(false);
}
});
});
describe('pathNamesMailFolder', () => {
it('spots a folder link behind any mount and locale prefix', () => {
expect(pathNamesMailFolder('/mail/folder/trash')).toBe(true);
expect(pathNamesMailFolder('/en/mail/folder/inbox')).toBe(true);
expect(pathNamesMailFolder('/webmail/de/mail/folder/Mxyz/')).toBe(true);
});
it('is false for the bare app, other mail links and other surfaces', () => {
for (const path of ['/', '/en', '/en/mail', '/mail/folder/', '/en/mail/message/m1', '/en/calendar', '/email/folder/x']) {
expect(pathNamesMailFolder(path), path).toBe(false);
}
});
});
describe('mail paths', () => {
it('round-trips folder, message and thread links', () => {
const folder = buildMailPath({ mailboxId: 'c', emailId: null, threadId: null }, MAILBOXES);
expect(folder).toBe('/mail/folder/c');
expect(parseMailPath(['folder', 'c'])).toEqual({ kind: 'folder', ref: 'c', accountId: undefined });
const message = buildMailPath({ mailboxId: 'a', emailId: 'm1', threadId: null }, MAILBOXES);
expect(message).toBe('/mail/message/m1');
expect(parseMailPath(['message', 'm1'])).toEqual({ kind: 'message', id: 'm1', accountId: undefined });
const thread = buildMailPath({ mailboxId: 'a', emailId: 'm1', threadId: 't1' }, MAILBOXES);
expect(thread).toBe('/mail/thread/t1');
expect(parseMailPath(['thread', 't1'])).toEqual({ kind: 'thread', id: 't1', accountId: undefined });
});
it('reads the fullscreen marker on message links', () => {
expect(parseMailPath(['message', 'm1'], new URLSearchParams('view=fullscreen'))).toEqual({
kind: 'message',
id: 'm1',
accountId: undefined,
fullscreen: true,
});
expect(parseMailPath(['message', 'm1'], new URLSearchParams('view=other'))).toEqual({
kind: 'message',
id: 'm1',
accountId: undefined,
});
});
it('reads the login slot a push notification names', () => {
expect(parseMailPath(['message', 'm1'], new URLSearchParams('slot=2'))).toEqual({
kind: 'message', id: 'm1', accountId: undefined, slot: 2,
});
expect(parseMailPath(['folder', 'inbox'], new URLSearchParams('slot=0'))).toEqual({
kind: 'folder', ref: 'inbox', accountId: undefined, slot: 0,
});
for (const bad of ['slot=', 'slot=-1', 'slot=1.5', 'slot=x']) {
expect(parseMailPath(['message', 'm1'], new URLSearchParams(bad))).toEqual({
kind: 'message', id: 'm1', accountId: undefined,
});
}
});
it('falls back to the bare mail path with nothing selected', () => {
expect(buildMailPath({ mailboxId: null, emailId: null, threadId: null })).toBe('/mail');
expect(parseMailPath([])).toBeNull();
});
it('percent-encodes ids that are not path-safe', () => {
const path = buildMailPath({ mailboxId: null, emailId: 'a/b c?d', threadId: null });
expect(path).toBe('/mail/message/a%2Fb%20c%3Fd');
const segment = path.split('/').pop()!;
expect(parseMailPath(['message', segment])).toEqual({
kind: 'message',
id: 'a/b c?d',
accountId: undefined,
});
});
it('survives a malformed percent-sequence instead of throwing', () => {
expect(parseMailPath(['message', '%E0%A4%A'])).toEqual({
kind: 'message',
id: '%E0%A4%A',
accountId: undefined,
});
});
it('carries the account disambiguator', () => {
const link = parseMailPath(['message', 'm1'], new URLSearchParams('account=acc-2'));
expect(link).toEqual({ kind: 'message', id: 'm1', accountId: 'acc-2' });
});
it('still understands the legacy ?email= notification link', () => {
expect(parseMailPath([], new URLSearchParams('email=m9'))).toEqual({
kind: 'message',
id: 'm9',
accountId: undefined,
});
});
it('ignores unknown segments rather than inventing an intent', () => {
expect(parseMailPath(['nonsense', 'x'])).toBeNull();
expect(parseMailPath(['message'])).toBeNull();
});
});
describe('calendar paths', () => {
it('formats and parses dates in local time', () => {
const date = new Date(2026, 7, 6);
expect(formatLinkDate(date)).toBe('2026-08-06');
expect(parseLinkDate('2026-08-06')?.getTime()).toBe(date.getTime());
});
it('rejects malformed and impossible dates', () => {
expect(parseLinkDate('2026-8-6')).toBeNull();
expect(parseLinkDate('2026-02-31')).toBeNull();
expect(parseLinkDate('nope')).toBeNull();
});
it('round-trips a view and date', () => {
const path = buildCalendarPath({ view: 'day', date: new Date(2026, 7, 6) });
expect(path).toBe('/calendar/day/2026-08-06');
const link = parseCalendarPath(['day', '2026-08-06']);
expect(link?.kind).toBe('view');
expect(link).toMatchObject({ view: 'day' });
});
it('round-trips an event, with the account that owns it', () => {
expect(buildCalendarPath({ view: 'month', date: null, eventId: 'e1' })).toBe('/calendar/event/e1');
expect(buildCalendarPath({ view: 'month', date: null, eventId: 'e1', accountId: 'acc-2' }))
.toBe('/calendar/event/e1?account=acc-2');
expect(parseCalendarPath(['event', 'e1'], new URLSearchParams('account=acc-2')))
.toEqual({ kind: 'event', id: 'e1', accountId: 'acc-2' });
});
it('accepts a bare date with no view', () => {
expect(parseCalendarPath(['2026-08-06'])).toMatchObject({ kind: 'view', view: 'month' });
});
it('returns null for an unknown view', () => {
expect(parseCalendarPath(['quarter'])).toBeNull();
expect(parseCalendarPath([])).toBeNull();
});
});
describe('contacts paths', () => {
it('round-trips detail and edit links', () => {
expect(buildContactsPath({ contactId: 'c1' })).toBe('/contacts/c1');
expect(buildContactsPath({ contactId: 'c1', editing: true })).toBe('/contacts/c1/edit');
expect(parseContactsPath(['c1'])).toEqual({ kind: 'contact', id: 'c1', edit: false, fromEmail: false });
expect(parseContactsPath(['c1', 'edit'])).toEqual({ kind: 'contact', id: 'c1', edit: true, fromEmail: false });
});
it('keeps the legacy query form working', () => {
expect(parseContactsPath([], new URLSearchParams('contactId=c1&view=edit&from=email')))
.toEqual({ kind: 'contact', id: 'c1', edit: true, fromEmail: true });
expect(parseContactsPath([], new URLSearchParams('addEmail=a%40b.c&addName=Ada')))
.toEqual({ kind: 'create', email: 'a@b.c', name: 'Ada', fromEmail: false });
});
it('handles the create link', () => {
expect(parseContactsPath(['new'], new URLSearchParams('email=a%40b.c')))
.toEqual({ kind: 'create', email: 'a@b.c', name: undefined, fromEmail: false });
});
});
describe('files paths', () => {
it('round-trips a nested folder', () => {
expect(buildFilesPath('/Documents/Invoices 2026')).toBe('/files/Documents/Invoices%202026');
expect(parseFilesPath(['Documents', 'Invoices%202026'])).toEqual({
path: '/Documents/Invoices 2026',
preview: undefined,
});
});
it('maps the drive root to the bare path', () => {
expect(buildFilesPath('/')).toBe('/files');
expect(parseFilesPath([])).toBeNull();
});
it('carries a file to preview', () => {
expect(buildFilesPath('/Documents', 'report.pdf')).toBe('/files/Documents?preview=report.pdf');
expect(parseFilesPath(['Documents'], new URLSearchParams('preview=report.pdf')))
.toEqual({ path: '/Documents', preview: 'report.pdf' });
});
});
describe('settings paths', () => {
it('round-trips a tab', () => {
expect(buildSettingsPath('appearance')).toBe('/settings/appearance');
expect(buildSettingsPath(null)).toBe('/settings');
expect(parseSettingsPath(['appearance'])).toBe('appearance');
expect(parseSettingsPath([])).toBeNull();
});
});
describe('matchSurface', () => {
it('maps the app root and /mail to the mail surface', () => {
expect(matchSurface('/')).toEqual({ surface: 'mail', segments: [] });
expect(matchSurface('/mail')).toEqual({ surface: 'mail', segments: [] });
expect(matchSurface('/mail/message/m1')).toEqual({ surface: 'mail', segments: ['message', 'm1'] });
});
it('ignores a leading locale segment', () => {
expect(matchSurface('/de/calendar/day/2026-08-06'))
.toEqual({ surface: 'calendar', segments: ['day', '2026-08-06'] });
expect(matchSurface('/de')).toEqual({ surface: 'mail', segments: [] });
});
it('strips query and hash', () => {
expect(matchSurface('/contacts/c1?from=email#x'))
.toEqual({ surface: 'contacts', segments: ['c1'] });
});
it('returns null for routes that are not app surfaces', () => {
expect(matchSurface('/pro')).toBeNull();
expect(matchSurface('/login')).toBeNull();
});
});