mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 08:12:43 +00:00
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.
157 lines
6.0 KiB
TypeScript
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);
|
|
});
|
|
});
|