// The two-part fallback gate. // // `lib/jmap/client.ts`'s read methods swallow their own errors and return // plausible success, so a "looks empty" result is NOT evidence of a network // failure - it is also what a genuinely empty folder returns, and // `getMailboxes()` fabricates a synthetic Inbox rather than throwing. Falling back // on the shape alone would serve stale replica rows over a folder the user had // just emptied. So the gate is: suspicious result AND a `fetch` rejection recorded // during that exact call. import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { Email, Mailbox } from '@/lib/jmap/types'; import { noteTransportFailure, resetTransportHealth } from '@/lib/jmap/transport-health'; const readOfflineMailboxes = vi.fn(); const readOfflineList = vi.fn(); const readOfflineMessage = vi.fn(); const isReplicaUnavailable = vi.fn(() => false); vi.mock('@/lib/offline-replica-client', () => ({ readOfflineMailboxes: (...a: unknown[]) => readOfflineMailboxes(...a), readOfflineList: (...a: unknown[]) => readOfflineList(...a), readOfflineMessage: (...a: unknown[]) => readOfflineMessage(...a), isReplicaUnavailable: () => isReplicaUnavailable(), })); vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts: [{ id: 'alice@mail.example.org', cookieSlot: 3, serverIdentifiers: [] }], }), }, })); const { withOfflineFallback } = await import('@/lib/offline-fallback-client'); function replicaEmail(id: string): Email { return { id, threadId: 't', mailboxIds: { inbox: true }, keywords: {}, size: 1, receivedAt: '2026-08-01T00:00:00.000Z', hasAttachment: false, htmlBody: [{ partId: '1', blobId: 'b', size: 1, type: 'text/html' }], bodyValues: { '1': { value: '

from the replica

' } }, }; } interface Stub extends Partial { getEmail: IJMAPClient['getEmail']; getEmails: IJMAPClient['getEmails']; getMailboxes: IJMAPClient['getMailboxes']; getAllMailboxes: IJMAPClient['getAllMailboxes']; } /** Reproduces the client's real error-swallowing shapes. */ function stubClient(overrides: Partial = {}): IJMAPClient { const stub = { getUsername: () => 'alice', getServerUrl: () => 'https://mail.example.org', getAccountId: () => 'primary', getEmail: async () => null, getEmails: async () => ({ emails: [] as Email[], hasMore: false, total: 0 }), getMailboxes: async () => ([ // The exact placeholder client.ts fabricates on failure. { id: 'INBOX', name: 'Inbox', role: 'inbox', sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, myRights: {} } as unknown as Mailbox, ]), getAllMailboxes: async () => ([ { id: 'INBOX', name: 'Inbox', role: 'inbox', sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, myRights: {} } as unknown as Mailbox, ]), ...overrides, }; return stub as unknown as IJMAPClient; } describe('withOfflineFallback', () => { beforeEach(() => { resetTransportHealth(); vi.clearAllMocks(); isReplicaUnavailable.mockReturnValue(false); }); it('does NOT consult the replica when the server answered "empty"', async () => { // The whole point. An empty folder must render empty, not as whatever the // replica last held. const client = withOfflineFallback(stubClient()); const result = await client.getEmails('inbox'); expect(result.emails).toEqual([]); expect(readOfflineList).not.toHaveBeenCalled(); expect(await client.getEmail('e1')).toBeNull(); expect(readOfflineMessage).not.toHaveBeenCalled(); }); it('consults the replica when a transport failure happened DURING the call', async () => { readOfflineList.mockResolvedValue({ emails: [replicaEmail('e1')], total: 1, hasMore: false, }); const client = withOfflineFallback( stubClient({ getEmails: async () => { // What authenticatedFetch does when `fetch` rejects. noteTransportFailure(); return { emails: [], hasMore: false, total: 0 }; }, }), ); const result = await client.getEmails('inbox', undefined, 25, 0); expect(result.emails.map((e) => e.id)).toEqual(['e1']); expect(result.total).toBe(1); // And it asks for the right slot, so a multi-account shell reads the right file. expect(readOfflineList).toHaveBeenCalledWith('inbox', { limit: 25, offset: 0, slot: 3 }); }); it('ignores a stale transport failure from BEFORE the call', async () => { // The counter is sampled per call precisely so an old failure cannot make a // later successful-but-empty read look offline. noteTransportFailure(); const client = withOfflineFallback(stubClient()); await client.getEmails('inbox'); expect(readOfflineList).not.toHaveBeenCalled(); }); it('serves a full message from the replica, but refuses an envelope-only hit', async () => { // An envelope with no bodyValues would render blank AND leave the viewer's // isBodyLoading gate stuck on its skeleton, which is worse than saying the // message is unavailable. readOfflineMessage.mockResolvedValue({ email: replicaEmail('e1'), hasBody: true }); const client = withOfflineFallback( stubClient({ getEmail: async () => { noteTransportFailure(); return null; } }), ); const email = await client.getEmail('e1'); expect(email?.bodyValues?.['1'].value).toContain('from the replica'); resetTransportHealth(); readOfflineMessage.mockResolvedValue({ email: replicaEmail('e2'), hasBody: false }); const client2 = withOfflineFallback( stubClient({ getEmail: async () => { noteTransportFailure(); return null; } }), ); expect(await client2.getEmail('e2')).toBeNull(); }); it('recognises the synthetic Inbox placeholder and replaces it', async () => { readOfflineMailboxes.mockResolvedValue([ { id: 'mb1', name: 'Inbox', role: 'inbox', sortOrder: 0, totalEmails: 9, unreadEmails: 2, totalThreads: 9, unreadThreads: 2, isSubscribed: true, myRights: {} } as unknown as Mailbox, ]); const client = withOfflineFallback( stubClient({ getAllMailboxes: async () => { noteTransportFailure(); return [ { id: 'INBOX', name: 'Inbox', role: 'inbox', sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, myRights: {} } as unknown as Mailbox, ]; }, }), ); const mailboxes = await client.getAllMailboxes(); expect(mailboxes.map((m) => m.id)).toEqual(['mb1']); }); it('keeps a REAL single-mailbox server result even after a transport failure', async () => { // A genuine server that happens to return one inbox has a real id and real // counts; only the exact placeholder shape may be replaced. const real = { id: 'real-inbox-id', name: 'Inbox', role: 'inbox', sortOrder: 0, totalEmails: 12, unreadEmails: 1, totalThreads: 12, unreadThreads: 1, isSubscribed: true, myRights: {}, } as unknown as Mailbox; const client = withOfflineFallback( stubClient({ getAllMailboxes: async () => { noteTransportFailure(); return [real]; } }), ); expect((await client.getAllMailboxes())[0].id).toBe('real-inbox-id'); expect(readOfflineMailboxes).not.toHaveBeenCalled(); }); it('never answers a read scoped to a delegated account', async () => { // v1 replicates the PRIMARY mail account only, so the replica has no rows for // a shared account and answering "empty" would be worse than the client's own. const client = withOfflineFallback( stubClient({ getEmails: async () => { noteTransportFailure(); return { emails: [], hasMore: false, total: 0 }; }, }), ); await client.getEmails('inbox', 'someone-elses-account'); expect(readOfflineList).not.toHaveBeenCalled(); }); it('never answers a keyword- or category-filtered query', async () => { // Those are server-side queries the replica does not reproduce. Serving an // unfiltered page in their place would silently show the wrong set. const failing = async () => { noteTransportFailure(); return { emails: [], hasMore: false, total: 0 }; }; const c1 = withOfflineFallback(stubClient({ getEmails: failing })); await c1.getEmails('inbox', undefined, 25, 0, '$flagged'); expect(readOfflineList).not.toHaveBeenCalled(); resetTransportHealth(); const c2 = withOfflineFallback(stubClient({ getEmails: failing })); await c2.getEmails('inbox', undefined, 25, 0, undefined, true, { from: 'x' }); expect(readOfflineList).not.toHaveBeenCalled(); }); it('stops asking once the replica reports itself absent', async () => { isReplicaUnavailable.mockReturnValue(true); const client = withOfflineFallback( stubClient({ getEmail: async () => { noteTransportFailure(); return null; } }), ); expect(await client.getEmail('e1')).toBeNull(); expect(readOfflineMessage).not.toHaveBeenCalled(); }); it('is idempotent, so re-wrapping a client does not stack fallbacks', async () => { readOfflineMessage.mockResolvedValue({ email: replicaEmail('e1'), hasBody: true }); const base = stubClient({ getEmail: async () => { noteTransportFailure(); return null; } }); const once = withOfflineFallback(base); const twice = withOfflineFallback(once); expect(twice).toBe(once); await twice.getEmail('e1'); expect(readOfflineMessage).toHaveBeenCalledTimes(1); }); });