Files
webmail/lib/__tests__/account-state-manager.test.ts
T
lucamzanon fac3bdf31c fix: scope batch selection to the owning account
Email ids are unique only within their account, so in a view that merges
several - the unified mailbox, and since #1038 the tag view - two
unrelated messages can share one. Selection was a set of bare ids
resolved with `emails.filter(e => selected.has(e.id))`, so ticking one
row also ticked its namesake in another account, and batch delete, move,
archive or mark-read then acted on a message nobody selected.

Reported by @alyyousuf7 on #1013, where `eiaaaabc` exists in two
accounts at once.

Selection is now keyed by `emailKeyFor` - the same account scoping
#1013 introduced for threads - and the actions take the email rather
than its id, so a caller cannot reintroduce the collision by passing a
bare id. Unstamped emails keep the bare id as their key, so
single-account views are unchanged.

The batch actions derive the JMAP ids from the selected emails
themselves instead of looking each id up again, which is where the
namesake used to ride along, and every place that pruned the selection
after a delete or a move now prunes by key.
2026-09-19 08:59:21 +02:00

157 lines
6.0 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
snapshotAccount,
restoreAccount,
clearAllStores,
evictAccount,
evictAll,
} from '@/lib/account-state-manager';
import { useEmailStore } from '@/stores/email-store';
import { useContactStore } from '@/stores/contact-store';
import { useCalendarStore } from '@/stores/calendar-store';
import { useFilterStore } from '@/stores/filter-store';
import { useIdentityStore } from '@/stores/identity-store';
import { useVacationStore } from '@/stores/vacation-store';
import { useFileStore } from '@/stores/file-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
import { makeEmail, makeMailbox } from './helpers/factories';
// The store singletons and the module-level snapshot cache persist across
// tests; reset both around every test.
beforeEach(() => evictAll());
afterEach(() => evictAll());
describe('snapshotAccount / restoreAccount', () => {
it('round-trips the captured fields across all six stores', () => {
useEmailStore.setState({
emails: [makeEmail({ id: 'a1' })],
mailboxes: [makeMailbox({ id: 'a-in' })],
selectedMailbox: 'a-in',
searchQuery: 'queryA',
quota: { used: 1, total: 2 },
});
useContactStore.setState({ supportsSync: true });
useCalendarStore.setState({ viewMode: 'week', supportsCalendar: true });
useFilterStore.setState({ isSupported: true });
useIdentityStore.setState({ preferredPrimaryId: 'idA' });
useVacationStore.setState({ isEnabled: true });
snapshotAccount('A');
// Mutate everything to "account B" values.
useEmailStore.setState({ emails: [], selectedMailbox: 'b-in', searchQuery: 'queryB', quota: null });
useContactStore.setState({ supportsSync: false });
useCalendarStore.setState({ viewMode: 'month', supportsCalendar: false });
useFilterStore.setState({ isSupported: false });
useIdentityStore.setState({ preferredPrimaryId: 'idB' });
useVacationStore.setState({ isEnabled: false });
expect(restoreAccount('A')).toBe(true);
expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['a1']);
expect(useEmailStore.getState().selectedMailbox).toBe('a-in');
expect(useEmailStore.getState().searchQuery).toBe('queryA');
expect(useEmailStore.getState().quota).toEqual({ used: 1, total: 2 });
expect(useContactStore.getState().supportsSync).toBe(true);
expect(useCalendarStore.getState().viewMode).toBe('week');
expect(useFilterStore.getState().isSupported).toBe(true);
expect(useIdentityStore.getState().preferredPrimaryId).toBe('idA');
expect(useVacationStore.getState().isEnabled).toBe(true);
});
it('resets fields outside the snapshot subset to their defaults (no cross-account leak)', () => {
// isLoading is NOT part of the email snapshot subset.
useEmailStore.setState({ selectedMailbox: 'a-in', isLoading: false });
snapshotAccount('A');
useEmailStore.setState({ selectedMailbox: 'b-in', isLoading: true });
restoreAccount('A');
expect(useEmailStore.getState().selectedMailbox).toBe('a-in'); // captured → restored
expect(useEmailStore.getState().isLoading).toBe(false); // uncaptured → reset, not leaked
});
it('decouples the snapshot from later in-place mutation of the source array', () => {
const arr = [makeEmail({ id: '1' })];
useEmailStore.setState({ emails: arr });
snapshotAccount('A');
arr.push(makeEmail({ id: '2' })); // mutate the same array after snapshot
useEmailStore.setState({ emails: [] });
restoreAccount('A');
// The post-snapshot mutation did NOT leak into the snapshot.
expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['1']);
});
it('returns false and leaves stores untouched for an unknown account', () => {
useEmailStore.setState({ searchQuery: 'keep' });
expect(restoreAccount('nope')).toBe(false);
expect(useEmailStore.getState().searchQuery).toBe('keep');
});
});
describe('clearAllStores', () => {
it('resets the email store to fresh empty collections', () => {
useEmailStore.setState({
emails: [makeEmail({ id: 'x' })],
selectedEmailKeys: new Set(['x']),
searchQuery: 'q',
tagCounts: { a: { total: 1, unread: 0 } },
threadEmailsCache: new Map([['t', []]]),
});
clearAllStores();
const s = useEmailStore.getState();
expect(s.emails).toEqual([]);
expect(s.searchQuery).toBe('');
expect(s.selectedEmailKeys.size).toBe(0);
expect(s.threadEmailsCache.size).toBe(0);
expect(s.tagCounts).toEqual({});
expect(s.searchFilters).toEqual(DEFAULT_SEARCH_FILTERS);
});
it('resets the account-scoped Files drive (no stale resources across a switch)', () => {
// Simulate account A having opened the drive: client attached, a folder
// navigated into, and its listing loaded.
const fakeClient = {} as never;
useFileStore.getState().initClient(fakeClient, 'A');
useFileStore.setState({
resources: [{ id: 'r1', name: 'A-secret.txt' } as never],
pathStack: [{ id: null, name: '' }, { id: 'folderA', name: 'Folder A' }],
currentPath: '/Folder A',
currentParentId: 'folderA',
supportsFiles: true,
selectedResources: new Set(['r1']),
});
clearAllStores();
const f = useFileStore.getState();
expect(f.client).toBeNull();
expect(f.currentAccountId).toBeNull();
expect(f.resources).toEqual([]);
expect(f.selectedResources.size).toBe(0);
expect(f.currentParentId).toBeNull();
expect(f.currentPath).toBe('/');
expect(f.pathStack).toEqual([{ id: null, name: '' }]);
expect(f.supportsFiles).toBeNull();
});
});
describe('evictAccount / evictAll', () => {
it('evictAccount drops a single snapshot', () => {
snapshotAccount('A');
evictAccount('A');
expect(restoreAccount('A')).toBe(false);
});
it('evictAll drops every snapshot', () => {
snapshotAccount('B');
snapshotAccount('C');
evictAll();
expect(restoreAccount('B')).toBe(false);
expect(restoreAccount('C')).toBe(false);
});
});