mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
324 lines
16 KiB
TypeScript
324 lines
16 KiB
TypeScript
import 'fake-indexeddb/auto';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
import { useAccountStore } from '@/stores/account-store';
|
|
import { usePolicyStore } from '@/stores/policy-store';
|
|
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
|
|
import { setStoreBackend } from '@/lib/store/client/api';
|
|
import { FakeJmapDrafts } from '@/lib/store/__tests__/helpers/fake-drafts';
|
|
import { JMAP_ACCOUNT } from '@/lib/store/__tests__/helpers/fake-jmap';
|
|
import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store';
|
|
import { consumeLost } from '../lost';
|
|
import { offlineNotify, refreshQueueState, setOfflineNotifier, startOfflineMode, unsentCount, wipeAccount, wipeEverything } from '../offline-mode';
|
|
import { flushQueue, forgetOrphanCheck } from '../flush';
|
|
import { heldFile, holdFile, noteDraftReplacedElsewhere, queueSend, readLocalDrafts, retryLocalDraft } from '../queue';
|
|
import { forgetLegacyMove } from '../migrate-legacy';
|
|
import { useOfflineStore, writeEnabled } from '../state';
|
|
import type { JMAPClient } from '@/lib/jmap/client';
|
|
|
|
let server: FakeJmapDrafts;
|
|
let store: InProcessStore;
|
|
|
|
function fakeClient(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
getServerUrl: () => 'https://mail.example.org',
|
|
getUsername: () => 'alice@example.org',
|
|
getAccountId: () => JMAP_ACCOUNT,
|
|
isOfflineOnly: () => false,
|
|
getSessionSnapshot: () => null,
|
|
getAuthHeader: () => 'Basic x',
|
|
keywordSortInverted: () => null,
|
|
sendEmail: vi.fn(async () => ({ scheduled: false, emailId: 'sent-1', emailSubmissionId: 'sub-1' })),
|
|
uploadBlob: vi.fn(async (file: File) => server.upload(JMAP_ACCOUNT, new Uint8Array(await file.arrayBuffer()))),
|
|
requestNetworkOnly: vi.fn((calls: never) => server.request(calls)),
|
|
resolveSendAccountId: vi.fn(async () => JMAP_ACCOUNT),
|
|
submissionAccountFor: () => JMAP_ACCOUNT,
|
|
pruneEmailSubmissions: vi.fn(async () => {}),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
let stop: () => void = () => {};
|
|
|
|
async function saveOffline(subject: string, attachments?: Array<Record<string, unknown>>): Promise<string> {
|
|
const answer = await store.answer('acc', [['Email/set', {
|
|
accountId: JMAP_ACCOUNT,
|
|
create: {
|
|
c: {
|
|
messageId: [`${subject}@example.org`],
|
|
subject,
|
|
keywords: { $seen: true, $draft: true },
|
|
mailboxIds: { drafts: true },
|
|
bodyValues: { 1: { value: 'text' } },
|
|
textBody: [{ partId: '1' }],
|
|
...(attachments ? { attachments } : {}),
|
|
},
|
|
},
|
|
}, 's']]);
|
|
return answer?.methodResponses[0][1].created.c.id as string;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
server = new FakeJmapDrafts({ mailboxes: [{ id: 'inbox', role: 'inbox' }, { id: 'drafts', role: 'drafts' }, { id: 'sent', role: 'sent' }] });
|
|
server.now = Date.now();
|
|
store = new InProcessStore(server);
|
|
setStoreBackend(store);
|
|
forgetLegacyMove();
|
|
forgetOrphanCheck();
|
|
localStorage.clear();
|
|
useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, conflicts: [], localDrafts: [], browserOffline: false, downServers: [], startedOffline: false });
|
|
usePolicyStore.setState({ loaded: true, subject: null, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } });
|
|
useAccountStore.setState({ accounts: [{ id: 'acc', label: 'alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] });
|
|
const client = fakeClient();
|
|
useAuthStore.setState({ client: client as never, activeAccountId: 'acc' });
|
|
writeEnabled(['acc']);
|
|
stop = startOfflineMode();
|
|
await store.sync('acc');
|
|
});
|
|
|
|
afterEach(async () => {
|
|
stop();
|
|
await wipeEverything();
|
|
setStoreBackend(null);
|
|
});
|
|
|
|
describe('flushQueue and drafts', () => {
|
|
it('uploads a draft\'s files, creates it, tells the page, and leaves nothing behind', async () => {
|
|
const client = fakeClient();
|
|
const held = await holdFile('acc', new File(['hello'], 'note.txt', { type: 'text/plain' }), 'note.txt');
|
|
await saveOffline('With a file', [{ blobId: held.blobId, type: 'text/plain', name: 'note.txt' }]);
|
|
const onDraftSent = vi.fn();
|
|
await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true, onDraftSent });
|
|
expect(server.drafts().map((email) => email.subject)).toEqual(['With a file']);
|
|
expect(client.uploadBlob).toHaveBeenCalledWith(expect.any(File), { accountId: JMAP_ACCOUNT, noHold: true });
|
|
expect(onDraftSent).toHaveBeenCalledTimes(1);
|
|
expect(await readLocalDrafts('acc')).toEqual([]);
|
|
});
|
|
|
|
it('holds a large file in pieces and reads it back whole', async () => {
|
|
const size = 2 * 1024 * 1024 + 3;
|
|
const held = await holdFile('acc', new File([new Uint8Array(size).fill(4)], 'big.bin'), 'big.bin');
|
|
expect(held.size).toBe(size);
|
|
const area = vi.spyOn(store, 'area');
|
|
const file = await heldFile('acc', held.blobId);
|
|
expect(file?.name).toBe('big.bin');
|
|
expect(file?.size).toBe(size);
|
|
expect(new Uint8Array(await (file as File).arrayBuffer()).every((value) => value === 4)).toBe(true);
|
|
area.mockRestore();
|
|
});
|
|
|
|
it('tells the user about a refused draft, keeps it, and lists it with its error', async () => {
|
|
const client = fakeClient();
|
|
server.refuseCreate = { type: 'overQuota', description: 'No room' };
|
|
const id = await saveOffline('Too much');
|
|
const notify = vi.fn();
|
|
await flushQueue('acc', client as unknown as JMAPClient, { notify, allowed: () => true });
|
|
expect(notify).toHaveBeenCalledWith({ kind: 'draft-refused', subject: 'Too much', error: { type: 'overQuota', text: 'No room' } });
|
|
await refreshQueueState();
|
|
expect(useOfflineStore.getState().localDrafts).toMatchObject([{ account: 'acc', localId: id, status: 'refused' }]);
|
|
|
|
server.refuseCreate = null;
|
|
await retryLocalDraft('acc', id);
|
|
await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true });
|
|
expect(server.drafts()).toHaveLength(1);
|
|
});
|
|
|
|
it('stops on an outage and carries on with what waited after it', async () => {
|
|
const client = fakeClient();
|
|
await saveOffline('One');
|
|
server.offline = true;
|
|
const sendEmail = client.sendEmail;
|
|
const entry = await queueSend('acc', { subject: 'Queued', to: [] }, { kind: 'email', args: [['b@example.org'], 'Queued', 'x'] });
|
|
await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true });
|
|
expect(sendEmail).not.toHaveBeenCalled();
|
|
server.offline = false;
|
|
await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true });
|
|
expect(server.drafts()).toHaveLength(1);
|
|
expect(sendEmail).toHaveBeenCalledTimes(1);
|
|
expect(entry).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('sends a draft kept here through the send path with its files, and the draft doesn\'t go up as well', async () => {
|
|
const held = await holdFile('acc', new File(['hello'], 'note.txt', { type: 'text/plain' }), 'note.txt');
|
|
const id = await saveOffline('To send', [{ blobId: held.blobId, type: 'text/plain', name: 'note.txt' }]);
|
|
const client = fakeClient({
|
|
// As the client does: the draft goes once the message is out.
|
|
sendEmail: vi.fn(async () => {
|
|
await store.answer('acc', [['Email/set', { accountId: JMAP_ACCOUNT, destroy: [id] }, 'd']]);
|
|
return { scheduled: false, emailId: 'sent-1', emailSubmissionId: 'sub-1' };
|
|
}),
|
|
});
|
|
await queueSend('acc', { subject: 'To send', to: [] }, {
|
|
kind: 'email',
|
|
args: [['b@example.org'], 'To send', 'x', undefined, undefined, 'i', 'alice@example.org', id, undefined, undefined, [{ blobId: held.blobId, name: 'note.txt', type: 'text/plain', size: 5 }]],
|
|
});
|
|
await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true });
|
|
expect(client.sendEmail).toHaveBeenCalledTimes(1);
|
|
expect(((client.sendEmail as ReturnType<typeof vi.fn>).mock.calls[0] as unknown[])[10]).toMatchObject([{ blobId: server.uploads[0].blobId }]);
|
|
expect(server.drafts()).toEqual([]);
|
|
expect(await readLocalDrafts('acc')).toEqual([]);
|
|
});
|
|
|
|
it('drops the draft of a send the lookup found went out', async () => {
|
|
const id = await saveOffline('Went out');
|
|
const client = fakeClient();
|
|
server.addEmail({ id: 'in-sent', subject: 'Went out', mailboxIds: { sent: true }, messageId: ['fixed@example.org'], receivedAt: new Date().toISOString() });
|
|
const entryId = await queueSend('acc', { subject: 'Went out', to: [] }, {
|
|
kind: 'email',
|
|
args: [['b@example.org'], 'Went out', 'x', undefined, undefined, 'i', 'alice@example.org', id],
|
|
}, undefined, 'fixed@example.org');
|
|
await store.area('acc', 'outbox.claim', { id: entryId });
|
|
await store.area('acc', 'outbox.update', { id: entryId, status: 'uncertain' });
|
|
await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true });
|
|
expect(await readLocalDrafts('acc')).toEqual([]);
|
|
expect(server.drafts()).toEqual([]);
|
|
});
|
|
|
|
it('cleans held files nothing needs, only long after', async () => {
|
|
const client = fakeClient();
|
|
const orphan = await holdFile('acc', new File(['a'], 'a.txt'), 'a.txt');
|
|
await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true });
|
|
expect(await heldFile('acc', orphan.blobId)).not.toBeNull();
|
|
const now = Date.now();
|
|
const clock = vi.spyOn(Date, 'now').mockReturnValue(now + 8 * 24 * 60 * 60 * 1000);
|
|
await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true });
|
|
clock.mockRestore();
|
|
expect(await heldFile('acc', orphan.blobId)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('a notice before the host is there', () => {
|
|
it('waits for it, and comes once', async () => {
|
|
vi.resetModules();
|
|
const fresh = await import('../offline-mode');
|
|
fresh.offlineNotify({ kind: 'store-reset' });
|
|
const show = vi.fn();
|
|
fresh.setOfflineNotifier(show);
|
|
expect(show).toHaveBeenCalledTimes(1);
|
|
expect(show).toHaveBeenCalledWith({ kind: 'store-reset' });
|
|
fresh.setOfflineNotifier(vi.fn());
|
|
expect(show).toHaveBeenCalledTimes(1);
|
|
vi.resetModules();
|
|
});
|
|
|
|
it('goes straight to a host that\'s there', () => {
|
|
const show = vi.fn();
|
|
setOfflineNotifier(show);
|
|
offlineNotify({ kind: 'store-reset' });
|
|
expect(show).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('what a sign-out asks about', () => {
|
|
it('counts a draft only on this device', async () => {
|
|
await saveOffline('Only here');
|
|
expect(await unsentCount('acc')).toMatchObject({ messages: 1 });
|
|
});
|
|
|
|
it('counts a draft a waiting send names once, as that send', async () => {
|
|
const id = await saveOffline('Being sent');
|
|
await queueSend('acc', { subject: 'Being sent', to: [] }, { kind: 'email', args: [['b@example.org'], 'Being sent', 'x', undefined, undefined, 'i', 'alice@example.org', id] });
|
|
expect(await unsentCount('acc')).toMatchObject({ messages: 1 });
|
|
});
|
|
});
|
|
|
|
describe('a wipe and local drafts', () => {
|
|
it('names them as lost when the user didn\'t agree', async () => {
|
|
// A second account stays, so the wipe doesn't take the whole store.
|
|
await saveOffline('Only here');
|
|
await refreshQueueState();
|
|
consumeLost();
|
|
await wipeAccount('acc');
|
|
expect(consumeLost()).toEqual(['Only here']);
|
|
});
|
|
});
|
|
|
|
describe('a flush that stops', () => {
|
|
it('tries again soon when a passing failure stopped it', async () => {
|
|
await saveOffline('One');
|
|
server.offline = true;
|
|
const retryLater = vi.fn();
|
|
await flushQueue('acc', fakeClient() as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true, retryLater });
|
|
expect(retryLater).toHaveBeenCalled();
|
|
});
|
|
|
|
it('doesn\'t when nothing stopped it', async () => {
|
|
await saveOffline('One');
|
|
const retryLater = vi.fn();
|
|
await flushQueue('acc', fakeClient() as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true, retryLater });
|
|
expect(retryLater).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('a reload in the middle of a flush', () => {
|
|
const nav = navigator as Navigator & { locks?: unknown };
|
|
afterEach(() => {
|
|
Object.defineProperty(nav, 'locks', { configurable: true, value: undefined });
|
|
});
|
|
|
|
async function leftSending() {
|
|
const id = await saveOffline('Left on the way');
|
|
expect(await store.area('acc', 'drafts.claim', { localId: id })).toBe(true);
|
|
return id;
|
|
}
|
|
|
|
it('goes on at once under the flush lock: nobody else can be sending it', async () => {
|
|
Object.defineProperty(nav, 'locks', { configurable: true, value: { request: (_name: string, run: () => Promise<unknown>) => run() } });
|
|
await leftSending();
|
|
await flushQueue('acc', fakeClient() as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true });
|
|
expect(server.drafts()).toHaveLength(1);
|
|
});
|
|
|
|
it('waits the minute without Web Locks, because another tab may be on it', async () => {
|
|
await leftSending();
|
|
await flushQueue('acc', fakeClient() as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true });
|
|
expect(server.drafts()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('an old version in another login', () => {
|
|
function clients() {
|
|
const other = { ...fakeClient(), deleteEmail: vi.fn(async () => {}) };
|
|
useAuthStore.setState({ getClientForAccount: ((id: string) => (id === 'other' ? other : undefined)) as never });
|
|
return other;
|
|
}
|
|
|
|
it('stays until the new version is on the server, then goes through its own login', async () => {
|
|
const other = clients();
|
|
server.addEmail({ id: 'R', subject: 'Elsewhere', mailboxIds: { drafts: true }, keywords: { $draft: true } });
|
|
const id = await saveOffline('New');
|
|
await noteDraftReplacedElsewhere('acc', id, { login: 'other', jmap: JMAP_ACCOUNT, id: 'R' });
|
|
expect(other.deleteEmail).not.toHaveBeenCalled();
|
|
server.refuseCreate = { type: 'overQuota' };
|
|
await flushQueue('acc', fakeClient() as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true });
|
|
expect(other.deleteEmail).not.toHaveBeenCalled();
|
|
server.refuseCreate = null;
|
|
await retryLocalDraft('acc', id);
|
|
await flushQueue('acc', fakeClient() as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true });
|
|
expect(other.deleteEmail).toHaveBeenCalledWith('R', JMAP_ACCOUNT);
|
|
});
|
|
|
|
it('stays when it\'s no longer a draft there', async () => {
|
|
const other = clients();
|
|
server.addEmail({ id: 'R', subject: 'Sent elsewhere', mailboxIds: { drafts: true }, keywords: {} });
|
|
const id = await saveOffline('New');
|
|
await noteDraftReplacedElsewhere('acc', id, { login: 'other', jmap: JMAP_ACCOUNT, id: 'R' });
|
|
await flushQueue('acc', fakeClient() as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true });
|
|
expect(other.deleteEmail).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('carries over to the next save, and waits for that login to be there', async () => {
|
|
useAuthStore.setState({ getClientForAccount: (() => undefined) as never });
|
|
server.addEmail({ id: 'R', subject: 'Elsewhere', mailboxIds: { drafts: true }, keywords: { $draft: true } });
|
|
const first = await saveOffline('First');
|
|
await noteDraftReplacedElsewhere('acc', first, { login: 'other', jmap: JMAP_ACCOUNT, id: 'R' });
|
|
const second = await saveOffline('Second');
|
|
await store.answer('acc', [['Email/set', { accountId: JMAP_ACCOUNT, destroy: [first], replacedBy: second }, 'd']]);
|
|
await flushQueue('acc', fakeClient() as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true });
|
|
expect(server.emails.has('R')).toBe(true);
|
|
const other = clients();
|
|
await flushQueue('acc', fakeClient() as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true });
|
|
expect(other.deleteEmail).toHaveBeenCalledWith('R', JMAP_ACCOUNT);
|
|
});
|
|
});
|