mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
119 lines
5.5 KiB
TypeScript
119 lines
5.5 KiB
TypeScript
import { beforeAll, expect, it, vi } from 'vitest';
|
|
import { createConfig } from '@/lib/__tests__/fixtures/config';
|
|
|
|
// Two overlapping switchAccount() calls (click account B, then C before the
|
|
// first switch lands) used to snapshot B's live stores under account A's
|
|
// key. Switching back to A then restored B's identities - so mail went out
|
|
// with B's From - and they were never re-fetched, since the restore
|
|
// "succeeded".
|
|
const IDS: Record<string, Array<{ id: string; email: string; name: string; mayDelete: boolean }>> = {
|
|
'a@x.test': [{ id: 'a', email: 'a@x.test', name: 'Alice', mayDelete: false }],
|
|
'b@y.test': [{ id: 'a', email: 'b@y.test', name: 'Bob', mayDelete: false }],
|
|
'c@z.test': [{ id: 'a', email: 'c@z.test', name: 'Carol', mayDelete: false }],
|
|
};
|
|
const DELAY: Record<string, number> = {};
|
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
vi.mock('@/lib/jmap/client', () => {
|
|
class JMAPClient {
|
|
constructor(public serverUrl: string, public username: string, public password: string) {}
|
|
static withBearer() { throw new Error('not used'); }
|
|
async connect() {}
|
|
getSessionUsername() { return this.username; }
|
|
getUsername() { return this.username; }
|
|
async getIdentities() { await sleep(DELAY[this.username] ?? 0); return IDS[this.username]; }
|
|
getAuthHeader() { return 'Basic eA=='; }
|
|
onConnectionChange() {}
|
|
onRateLimit() {}
|
|
getRateLimitRemainingMs() { return 0; }
|
|
supportsContacts() { return false; }
|
|
supportsPrincipals() { return false; }
|
|
supportsVacationResponse() { return false; }
|
|
supportsCalendars() { return false; }
|
|
supportsSieve() { return false; }
|
|
disconnect() {}
|
|
getAccountId() { return 'acct'; }
|
|
}
|
|
class RateLimitError extends Error {}
|
|
return { JMAPClient, RateLimitError };
|
|
});
|
|
vi.mock('@/lib/stalwart/principal', () => ({ fetchPrincipalDisplayName: async () => null }));
|
|
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
import { useAccountStore } from '@/stores/account-store';
|
|
import { useIdentityStore } from '@/stores/identity-store';
|
|
import { useEmailStore } from '@/stores/email-store';
|
|
import { useSettingsStore } from '@/stores/settings-store';
|
|
import { evictAccount } from '@/lib/account-state-manager';
|
|
|
|
const acct = (username: string, host: string, slot: number) => ({
|
|
id: `${username}@${host}`, label: username, serverUrl: `https://${host}`, username, authMode: 'basic' as const,
|
|
rememberMe: true, cookieSlot: slot, displayName: username, email: username, isConnected: false, hasError: false,
|
|
lastLoginAt: 1, avatarColor: '#000', isDefault: slot === 0,
|
|
});
|
|
const A = acct('a@x.test', 'mail.x.test', 0);
|
|
const B = acct('b@y.test', 'mail.y.test', 1);
|
|
const C = acct('c@z.test', 'mail.z.test', 2);
|
|
|
|
beforeAll(() => {
|
|
vi.stubGlobal('fetch', vi.fn(async (input: unknown) => {
|
|
const url = String(input);
|
|
const m = url.match(/\/api\/auth\/session\?slot=(\d)/);
|
|
if (m) {
|
|
const a = [A, B, C][Number(m[1])];
|
|
return new Response(JSON.stringify({ serverUrl: a.serverUrl, username: a.username, password: 'pw' }), { status: 200 });
|
|
}
|
|
if (url.includes('/api/config')) return new Response(JSON.stringify(createConfig()), { status: 200 });
|
|
return new Response('{}', { status: 200 });
|
|
}));
|
|
});
|
|
|
|
it("overlapping switches keep every account's data under its own key", async () => {
|
|
useAccountStore.setState({ accounts: [A, B, C] as never, activeAccountId: A.id, defaultAccountId: A.id });
|
|
useAuthStore.setState({ isAuthenticated: true, activeAccountId: A.id, client: null });
|
|
const sw = (id: string) => useAuthStore.getState().switchAccount(id);
|
|
// Connect all three logins (network path), ending on a freshly loaded A.
|
|
await sw(B.id); await sw(C.id); evictAccount(A.id); await sw(A.id);
|
|
useEmailStore.setState({ emails: [{ id: 'mA', subject: 'mail of A' }] as never });
|
|
|
|
// Click B, then C while B's session check (getIdentities) is still in flight.
|
|
DELAY['b@y.test'] = 5; DELAY['c@z.test'] = 40;
|
|
await Promise.all([sw(B.id), sw(C.id)]);
|
|
expect(useAuthStore.getState().activeAccountId).toBe(C.id);
|
|
expect(useIdentityStore.getState().identities.map((i) => i.email)).toEqual(['c@z.test']);
|
|
|
|
// Later: back to A.
|
|
DELAY['b@y.test'] = 0; DELAY['c@z.test'] = 0;
|
|
await sw(A.id);
|
|
const st = useAuthStore.getState();
|
|
expect((st.client as unknown as { username: string }).username).toBe('a@x.test');
|
|
expect(st.primaryIdentity?.email).toBe('a@x.test');
|
|
expect(useEmailStore.getState().emails.map((e) => e.subject)).toEqual(['mail of A']);
|
|
});
|
|
|
|
it("keeps the outgoing account's mail on screen while its settings are saved", async () => {
|
|
useAccountStore.setState({ accounts: [A, B] as never, activeAccountId: A.id, defaultAccountId: A.id });
|
|
useAuthStore.setState({ isAuthenticated: true, activeAccountId: A.id, client: null });
|
|
const sw = (id: string) => useAuthStore.getState().switchAccount(id);
|
|
// Connect both, so the next switch takes the seamless path.
|
|
await sw(B.id); await sw(A.id);
|
|
useEmailStore.setState({ emails: [{ id: 'mA', subject: 'mail of A' }] as never });
|
|
|
|
// The flush is a request; whatever the store holds meanwhile is on screen.
|
|
const flushSync = useSettingsStore.getState().flushSync;
|
|
let onScreen: string[] = [];
|
|
useSettingsStore.setState({
|
|
flushSync: async () => {
|
|
await sleep(0);
|
|
onScreen = useEmailStore.getState().emails.map((e) => e.subject ?? '');
|
|
},
|
|
});
|
|
try {
|
|
await sw(B.id);
|
|
} finally {
|
|
useSettingsStore.setState({ flushSync });
|
|
}
|
|
expect(onScreen).toEqual(['mail of A']);
|
|
expect(useAuthStore.getState().activeAccountId).toBe(B.id);
|
|
});
|