import { describe, it, expect, vi, afterEach } from 'vitest'; import { buildFolderRef, resolveFolderRef, isFolderLinkOpen, pathNamesMailFolder, buildMailPath, parseMailPath, buildCalendarPath, parseCalendarPath, formatLinkDate, parseLinkDate, buildContactsPath, parseContactsPath, buildFilesPath, buildSharedFilesPath, parseFilesPath, buildSettingsPath, parseSettingsPath, parseSettingsSection, 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 & { 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' }); }); // Email ids repeat across accounts: a link to a shared account's message // names that account, or it opens the own message with the same id (#1351). it('names the account owning a message or conversation outside the own one', () => { const message = buildMailPath({ mailboxId: null, emailId: 'm1', threadId: null, ownerAccountId: 'cn', loginId: 'bob@x', fullscreen: true }); expect(message).toBe('/mail/message/m1?account=bob%40x&owner=cn&view=fullscreen'); const [, query] = message.split('?'); expect(parseMailPath(['message', 'm1'], new URLSearchParams(query))).toEqual({ kind: 'message', id: 'm1', accountId: 'bob@x', ownerAccountId: 'cn', fullscreen: true, }); expect(buildMailPath({ mailboxId: null, emailId: null, threadId: 't1', ownerAccountId: 'cn' })).toBe('/mail/thread/t1?owner=cn'); expect(parseMailPath(['thread', 't1'], new URLSearchParams('owner=cn'))).toEqual({ kind: 'thread', id: 't1', accountId: undefined, ownerAccountId: 'cn', }); // Only a message link opens fullscreen; a folder link names no owner. expect(buildMailPath({ mailboxId: null, emailId: null, threadId: 't1', fullscreen: true })).toBe('/mail/thread/t1'); expect(buildMailPath({ mailboxId: 'c', emailId: null, threadId: null, ownerAccountId: 'cn' }, MAILBOXES)).toBe('/mail/folder/c'); }); 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' }); }); it('names a node of a shared drive by id, since it has no path under the user\'s root', () => { const link = buildSharedFilesPath('owner1:n5', 'Q3 plan.pdf'); expect(link).toBe('/files?shared=owner1%3An5&preview=Q3%20plan.pdf'); const [, query] = link.split('?'); expect(parseFilesPath([], new URLSearchParams(query))) .toEqual({ path: '/', preview: 'Q3 plan.pdf', shared: 'owner1:n5' }); // An owner's page of files shared on their own. expect(parseFilesPath([], new URLSearchParams(buildSharedFilesPath('owner1:*').split('?')[1]))) .toEqual({ path: '/', preview: undefined, shared: 'owner1:*' }); }); }); 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(); }); it('round-trips a section of a tab', () => { expect(buildSettingsPath('organize', 'tags')).toBe('/settings/organize/tags'); expect(buildSettingsPath('organize', null)).toBe('/settings/organize'); expect(parseSettingsPath(['organize', 'tags'])).toBe('organize'); expect(parseSettingsSection(['organize', 'tags'])).toBe('tags'); expect(parseSettingsSection(['organize'])).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(); }); });