From 33c5ca81255ea1e2be4e3110703388fc97746005 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:18:05 +0100 Subject: [PATCH] test: check bulk moves against the per-login routing --- .../email-store-bulk-move-routing.test.ts | 117 ++++++++---------- 1 file changed, 49 insertions(+), 68 deletions(-) diff --git a/stores/__tests__/email-store-bulk-move-routing.test.ts b/stores/__tests__/email-store-bulk-move-routing.test.ts index 98a3a1107..8ecd060f7 100644 --- a/stores/__tests__/email-store-bulk-move-routing.test.ts +++ b/stores/__tests__/email-store-bulk-move-routing.test.ts @@ -38,6 +38,7 @@ let clients: Map; let requests: Wire[]; let serverMailboxes: Map>; let refresh: ReturnType>; +let cross: ReturnType>; const serverKey = (login: string, owner: string, id: string) => JSON.stringify([login, owner, id]); function select(rows: Email[], selected = rows): void { @@ -54,19 +55,28 @@ function expectRequests(expected: Array<[string, string, string[], string?]>): v }))); } -async function expectPreflightFailure(): Promise { - const before = useEmailStore.getState(); - expect(await before.batchMoveToMailbox(a, 'dest')).toBe(false); - expect(fetch).not.toHaveBeenCalled(); - expect(requests).toEqual([]); - expect(refresh).not.toHaveBeenCalled(); +/** + * The rows of a login that is gone stay where they are, listed and selected, + * and nothing is written for them through another login; the rest move. + */ +async function expectNotConnected(stranded: Email, moved: Email): Promise { + await useEmailStore.getState().batchMoveToMailbox(a, 'dest'); + expectRequests([['a', 'owner', [moved.id]]]); + expect(cross).not.toHaveBeenCalled(); const after = useEmailStore.getState(); expect(after.error).toMatch(/not connected/i); expect(after.isLoading).toBe(false); - expect(after.emails).toBe(before.emails); - expect(after.selectedEmailKeys).toBe(before.selectedEmailKeys); - expect(after.selectedEmail).toBe(before.selectedEmail); - expect(after.threadEmailsCache).toBe(before.threadEmailsCache); + expect(after.emails).toEqual([stranded]); + expect([...after.selectedEmailKeys]).toEqual([emailKeyFor(stranded)]); +} + +/** The cross-account moves asked for: [from login, ids, owner]. */ +function crossMoves(): Array<[string, string[], string | undefined]> { + return cross.mock.calls.map(([sources, toLogin, mailboxId, toAccount, fromAccount]) => { + expect([toLogin, mailboxId, toAccount]).toEqual(['a', 'dest', undefined]); + const [[login, ids]] = [...sources]; + return [login, ids, fromAccount]; + }); } beforeEach(() => { @@ -74,6 +84,10 @@ beforeEach(() => { a = client('a'); b = client('b'); clients = new Map([['a', a], ['b', b]]); requests = []; serverMailboxes = new Map(); refresh = vi.fn().mockResolvedValue(undefined); + // "Move to" offers the active login's folders: a message of another login + // or account goes there across accounts (crossAccountMoveEmails has tests + // of its own), since `dest` names another folder, or none, in its account. + cross = vi.fn().mockResolvedValue(true); vi.stubGlobal('fetch', vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { const body = JSON.parse(String(init?.body)) as { methodCalls: Array<[string, SetArgs, string]> }; const responses = body.methodCalls.map(([method, args, tag]) => { @@ -103,7 +117,7 @@ beforeEach(() => { searchQuery: '', emails: [], selectedEmail: null, selectedEmailKeys: new Set(), mailboxes: [mailbox('inbox'), mailbox('dest')], accountMailboxes: {}, threadEmailsCache: new Map(), expandedThreadIds: new Set(), - error: null, isLoading: false, refreshCurrentMailbox: refresh, + error: null, isLoading: false, refreshCurrentMailbox: refresh, crossAccountMoveEmails: cross, }); }); @@ -115,47 +129,42 @@ afterEach(() => { }); describe('bulk Move authenticated-client routing', () => { - it('keeps colliding owner IDs on their authenticated clients, leaving an unselected namesake alone', async () => { + it('keeps colliding owner IDs on their own logins, leaving an unselected namesake alone', async () => { const selectedA = email('a', 'p'), namesakeA = email('a', 'q'), selectedB = email('b', 'q'); select([selectedA, namesakeA, selectedB], [selectedA, selectedB]); serverMailboxes.set(serverKey('a', 'owner', 'q'), { inbox: true }); expect(await useEmailStore.getState().batchMoveToMailbox(a, 'dest')).toBe(true); - expectRequests([['a', 'owner', ['p']], ['b', 'owner', ['q']]]); + expectRequests([['a', 'owner', ['p']]]); + expect(crossMoves()).toEqual([['b', ['q'], 'owner']]); expect(serverMailboxes.get(serverKey('a', 'owner', 'p'))).toEqual({ dest: true }); - expect(serverMailboxes.get(serverKey('b', 'owner', 'q'))).toEqual({ dest: true }); expect(serverMailboxes.get(serverKey('a', 'owner', 'q'))).toEqual({ inbox: true }); - expect(useEmailStore.getState().emails).toEqual([namesakeA]); - expect(useEmailStore.getState().selectedEmailKeys.size).toBe(0); + expect(useEmailStore.getState().emails).not.toContain(selectedA); + expect(useEmailStore.getState().emails).toContain(namesakeA); }); - it('sends equal selected email IDs separately through each client', async () => { + it('sends equal selected email IDs separately for each login', async () => { select([email('a', 'same'), email('b', 'same')]); expect(await useEmailStore.getState().batchMoveToMailbox(a, 'dest')).toBe(true); - expectRequests([['a', 'owner', ['same']], ['b', 'owner', ['same']]]); - expect(useEmailStore.getState().emails).toEqual([]); + expectRequests([['a', 'owner', ['same']]]); + expect(crossMoves()).toEqual([['b', ['same'], 'owner']]); }); - it('moves only the selected client copy when another client has the same ID', async () => { + it('moves only the selected login\'s copy when another login has the same ID', async () => { const other = email('a', 'same'), selected = email('b', 'same'); select([other, selected], [selected]); await useEmailStore.getState().batchMoveToMailbox(a, 'dest'); - expectRequests([['b', 'owner', ['same']]]); - expect(useEmailStore.getState().emails).toEqual([other]); + expectRequests([]); + expect(crossMoves()).toEqual([['b', ['same'], 'owner']]); + expect(useEmailStore.getState().emails).toContain(other); }); - it('keeps delegated owners separate when they share a reaching client', async () => { + it('keeps delegated owners separate when they share a reaching login', async () => { select([email('a', 'p'), email('a', 'q', 'shared')]); await useEmailStore.getState().batchMoveToMailbox(a, 'dest'); - expectRequests([['a', 'owner', ['p']], ['a', 'shared', ['q']]]); - }); - - it('groups aliases of the same effective client and owner into one request', async () => { - clients.set('alias-a', a); - select([email('a', 'p'), email('alias-a', 'q')]); - await useEmailStore.getState().batchMoveToMailbox(a, 'dest'); - expectRequests([['a', 'owner', ['p', 'q']]]); + expectRequests([['a', 'owner', ['p']]]); + expect(crossMoves()).toEqual([['a', ['q'], 'shared']]); }); it('preserves personal-folder routing for unstamped messages', async () => { @@ -189,55 +198,27 @@ describe('bulk Move authenticated-client routing', () => { expectRequests([['b', 'owner-b', ['p', 'q']]]); }); - it('honors source stamps after the aggregate-view flag has changed', async () => { - useEmailStore.setState({ isUnifiedView: false, selectedMailbox: 'inbox' }); - select([email('b', 'p'), email('b', 'q')]); - await useEmailStore.getState().batchMoveToMailbox(a, 'dest'); - expectRequests([['b', 'owner', ['p', 'q']]]); - }); - it('routes selected expanded-thread members without moving an unselected list namesake', async () => { const other = email('a', 'same'), member = email('b', 'same'); select([other], [member]); useEmailStore.setState({ threadEmailsCache: new Map([[threadKeyFor(member), [member]]]) }); await useEmailStore.getState().batchMoveToMailbox(a, 'dest'); - expectRequests([['b', 'owner', ['same']]]); + expectRequests([]); + expect(crossMoves()).toEqual([['b', ['same'], 'owner']]); expect(useEmailStore.getState().emails).toEqual([other]); }); - it.each(['owner', undefined])('preflights a disconnected explicit source (owner=%s) before sending any group', async owner => { + it('leaves a disconnected login\'s rows alone instead of using another login', async () => { clients.delete('b'); - select([email('a', 'p'), { ...email('b', 'q'), sourceAccountId: owner }]); - await expectPreflightFailure(); + const moved = email('a', 'p'), stranded = email('b', 'q'); + select([moved, stranded]); + await expectNotConnected(stranded, moved); }); it('rejects an owner stamp without a reaching login instead of using the active client', async () => { - select([email('a', 'p'), { ...email('b', 'q'), sourceClientAccountId: undefined }]); - await expectPreflightFailure(); - }); - - it.each([false, true])('preflights an unavailable viewing client (shared=%s)', async shared => { - clients.delete('b'); - useEmailStore.setState({ - isUnifiedView: false, selectedMailbox: shared ? 'shared:inbox' : 'inbox', viewingAccountId: 'b', - accountMailboxes: { b: [mailbox('inbox', 'shared', shared), mailbox('dest', 'shared', shared)] }, - }); - select([unstamped('p'), unstamped('q')]); - await expectPreflightFailure(); - }); - - it('preflights unavailable source stamps outside aggregate views too', async () => { - clients.delete('b'); - useEmailStore.setState({ isUnifiedView: false, selectedMailbox: 'inbox' }); - select([email('a', 'p'), email('b', 'q')]); - await expectPreflightFailure(); - }); - - it('preflights the viewing client even when a stamped group was resolved first', async () => { - clients.delete('b'); - useEmailStore.setState({ viewingAccountId: 'b' }); - select([email('a', 'p'), unstamped('q')]); - await expectPreflightFailure(); + const moved = email('a', 'p'), stranded = { ...email('b', 'q'), sourceClientAccountId: undefined }; + select([moved, stranded]); + await expectNotConnected(stranded, moved); }); it('preserves explicit-target routing without using the unrelated selection', async () => {