Files
webmail/lib/store/__tests__/drafts-client.test.ts
T
Linus Rath 60eef9c1ef chore: merge the reviewed mail branch, pop-outs and desktop core into the offline drafts branch
The flush keeps one `outbox.dropOrphans` caller, the mail branch's daily
one with its 7 days, and drafts' own query says what's needed, so
`addHeldFileSource` and its test are gone. `unsentCount` counts local
drafts, and a draft a waiting send names counts once. The scope check in
`recordBlob` and the permanent-status rule moved to
`lib/store/mail/attachments.ts`, with tests.
2026-11-03 10:40:52 +01:00

384 lines
18 KiB
TypeScript

/**
* The real JMAPClient's createDraft, getEmail and blob calls against the store
* while offline and a fake server online, through a stubbed fetch: what the
* composer does around a save.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { JMAPClient } from '@/lib/jmap/client';
import { setOfflineHooks, type OfflineMethodCall, type OfflineResponse } from '@/lib/jmap/offline-hooks';
import { DEFAULT_SCOPE } from '../area';
import type { DraftGhost, PendingDraft } from '../drafts/calls';
import { flushDrafts } from '../drafts/flush';
import { AccountEngine } from '../engine/account-engine';
import { TransportError, type MethodCall } from '../engine/transport';
import { mailArea } from '../mail';
import { outboxArea } from '../outbox';
import { flushPatches, type PatchOutcome, type PendingPatch } from '../outbox/flush-patches';
import { FakeJmapDrafts } from './helpers/fake-drafts';
import { JMAP_ACCOUNT } from './helpers/fake-jmap';
import { openMemoryStore } from './helpers/node-adapter';
const NOW = Date.UTC(2026, 10, 3);
const MAIL = 'urn:ietf:params:jmap:mail';
const SUBMISSION = 'urn:ietf:params:jmap:submission';
const STALWART = 'urn:stalwart:jmap';
const SESSION = {
capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInGet: 500, maxObjectsInSet: 500 }, [MAIL]: {}, [SUBMISSION]: {}, [STALWART]: {} },
accounts: { [JMAP_ACCOUNT]: { name: 'a@example.org', isPersonal: true, accountCapabilities: { [MAIL]: {}, [SUBMISSION]: {}, [STALWART]: {} } } },
primaryAccounts: { [MAIL]: JMAP_ACCOUNT },
username: 'a@example.org',
apiUrl: 'https://fake.test/api',
downloadUrl: 'https://fake.test/d/{accountId}/{blobId}/{name}?type={type}',
uploadUrl: 'https://fake.test/u/{accountId}',
eventSourceUrl: '',
state: 's',
};
function json(body: unknown): Response {
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
let server: FakeJmapDrafts;
let engine: AccountEngine;
let online: boolean;
/** The server comes back right after the next create was answered on the device. */
let backAfterCreate: boolean;
let spy: ReturnType<typeof vi.spyOn>;
let client: JMAPClient;
/** What reached the network. */
let wire: MethodCall[];
beforeEach(async () => {
server = new FakeJmapDrafts({ mailboxes: [{ id: 'inbox', role: 'inbox' }, { id: 'drafts', role: 'drafts' }, { id: 'sent', role: 'sent' }] });
server.now = NOW;
engine = new AccountEngine({
account: 'a@fake.test',
db: openMemoryStore(),
session: SESSION as never,
transport: server,
areas: [mailArea, outboxArea],
scope: { ...DEFAULT_SCOPE, days: null, attachments: false },
now: () => NOW,
sleep: async () => {},
});
online = true;
backAfterCreate = false;
wire = [];
spy = vi.spyOn(globalThis, 'fetch');
spy.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (!online) throw new TypeError('Failed to fetch');
if (url.includes('/u/')) {
const uploaded = await server.upload(JMAP_ACCOUNT, new Uint8Array(await (init?.body as Blob).arrayBuffer()));
return json(uploaded);
}
if (url.includes('/d/')) return new Response(new Uint8Array([9]), { status: 200 });
if (!init?.body) return json(SESSION);
const calls = (JSON.parse(String(init.body)) as { methodCalls: MethodCall[] }).methodCalls;
wire.push(...calls);
return json(await server.request(calls));
});
setOfflineHooks({
skipNetwork: () => !online,
answer: async (_client, calls: OfflineMethodCall[], options) => {
const marked = options?.networkTried
? calls.map((call): OfflineMethodCall => (call[0] === 'Email/set' && call[1].create ? [call[0], { ...call[1], networkTried: true }, call[2]] : call))
: calls;
const answer = await engine.answer(marked);
if (backAfterCreate && calls.some(([name, args]) => name === 'Email/set' && args.create)) online = true;
return answer as OfflineResponse | null;
},
record: () => {},
noteNetwork: () => {},
serverGone: async () => true,
blob: async (_client, blobId) => {
const held = await engine.areaCall('outbox.heldFile', { blobId }) as { blob: Blob } | null;
return held?.blob ?? null;
},
recordBlob: () => {},
holdUpload: async (_client, file) => engine.areaCall('outbox.holdFile', { name: file.name, type: file.type, data: new Uint8Array(await file.arrayBuffer()) }) as Promise<{ blobId: string; size: number; type: string }>,
resolveStandIns: (_client, ids) => engine.areaCall('drafts.resolveStandIns', { ids }) as Promise<Record<string, string | null>>,
resolveHeldAttachments: async (_client, attachments) => {
const out = [];
for (const attachment of attachments) {
const held = await engine.areaCall('outbox.heldFile', { blobId: attachment.blobId }) as { blob: Blob } | null;
out.push(held ? { ...attachment, blobId: (await server.upload(JMAP_ACCOUNT, new Uint8Array(await held.blob.arrayBuffer()))).blobId } : attachment);
}
return out;
},
});
await engine.requestSync();
client = new JMAPClient('https://fake.test', 'a@example.org', 'pw');
await client.connect();
wire.length = 0;
});
afterEach(() => {
spy.mockRestore();
setOfflineHooks(null);
});
const saveDraft = (text: string, previous?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number }>) =>
client.createDraft(['b@example.org'], 'Subject', text, undefined, undefined, undefined, 'a@example.org', previous, attachments);
async function flushAll(): Promise<void> {
await flushDrafts({
store: {
read: () => engine.areaCall('drafts.read', { before: NOW - 60_000 }) as Promise<{ rows: PendingDraft[]; ghosts: DraftGhost[] }>,
claim: (localId) => engine.areaCall('drafts.claim', { localId }) as Promise<boolean>,
failed: async (localId, status, error, neverSent) => { await engine.areaCall('drafts.failed', { localId, status, error, neverSent }); },
created: (result) => engine.areaCall('drafts.created', result) as Promise<{ conflicts: string[] }>,
ghostsLooked: async (jmap, messageIds, found) => { await engine.areaCall('drafts.ghostsLooked', { jmap, messageIds, found }); },
},
request: (calls) => server.request(calls),
upload: async (jmap, blobId) => {
const held = await engine.areaCall('outbox.heldFile', { blobId }) as { blob: Blob } | null;
return held ? (await server.upload(jmap, new Uint8Array(await held.blob.arrayBuffer()))).blobId : null;
},
isRefusal: (error) => error instanceof TransportError && error.kind === 'http',
onRefused: () => {},
onConflict: () => {},
onSent: () => {},
});
const pending = await engine.areaCall('outbox.pendingPatches', {}) as PendingPatch[];
await flushPatches({ request: (calls) => server.request(calls) }, pending, async (outcomes: PatchOutcome[]) => {
await engine.areaCall('outbox.patchResults', { outcomes });
});
}
describe('createDraft offline', () => {
it('saves on the device, and the composer can read the draft straight back', async () => {
online = false;
const id = await saveDraft('Written offline');
expect(id).toMatch(/^local-/);
expect(server.drafts()).toEqual([]);
const email = await client.getEmail(id);
expect(email?.id).toBe(id);
expect(email?.bodyValues?.['1']?.value).toBe('Written offline');
expect(email?.to?.[0]?.email).toBe('b@example.org');
});
it('merges saves, so a hundred leave one draft on the server with the latest text', async () => {
online = false;
let id = await saveDraft('Save 0');
for (let n = 1; n < 100; n += 1) id = await saveDraft(`Save ${n}`, id);
online = true;
await flushAll();
expect(server.drafts()).toHaveLength(1);
expect((server.drafts()[0].bodyValues as Record<string, { value: string }>)['1'].value).toBe('Save 99');
});
it('leaves the old version alone on the server, even when it comes back between the two steps', async () => {
const real = await saveDraft('Online version');
online = false;
backAfterCreate = true;
wire.length = 0;
const id = await saveDraft('Offline version', real);
expect(id).toMatch(/^local-/);
expect(online).toBe(true);
expect(server.emails.has(real)).toBe(true);
expect(wire.filter(([name, args]) => name === 'Email/set' && args.destroy)).toEqual([]);
await flushAll();
expect(server.drafts().map((email) => (email.bodyValues as Record<string, { value: string }>)['1'].value)).toEqual(['Offline version']);
});
it('gives each create a Message-ID of its own, online too, for finding it after a lost answer', async () => {
await saveDraft('One');
const create = wire.find(([name]) => name === 'Email/set')?.[1].create as Record<string, { messageId: string[] }>;
expect(Object.values(create)[0].messageId[0]).toMatch(/@example\.org$/);
});
it('marks a create whose connection broke, so the flush looks before it creates', async () => {
const original = spy.getMockImplementation();
spy.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
const body = init?.body ? String(init.body) : '';
if (body.includes('"Email/set"')) throw new TypeError('Failed to fetch');
return original!(input, init);
});
const id = await saveDraft('On the way');
spy.mockImplementation(original!);
expect(id).toMatch(/^local-/);
expect(await engine.areaCall('drafts.list', {})).toMatchObject([{ status: 'uncertain' }]);
});
});
describe('after a flush the page still holds the stand-in', () => {
it('reads the real message by it, and the answer says what the caller asked', async () => {
online = false;
const id = await saveDraft('Text');
online = true;
await flushAll();
const real = server.drafts()[0].id;
wire.length = 0;
const email = await client.getEmail(id);
expect(email?.id).toBe(real);
expect((wire.find(([name]) => name === 'Email/get')?.[1].ids as string[])).toEqual([real]);
});
it('replaces the real message on the next save, online', async () => {
online = false;
const id = await saveDraft('First');
online = true;
await flushAll();
const next = await saveDraft('Second', id);
expect(server.drafts().map((email) => email.id)).toEqual([next]);
});
it('also finds it offline again', async () => {
online = false;
const id = await saveDraft('First');
online = true;
await flushAll();
await engine.requestSync();
online = false;
const email = await client.getEmail(id);
expect(email?.bodyValues?.['1']?.value).toBe('First');
});
it('replaces the real message on the next save, offline, after the new one is up', async () => {
online = false;
const id = await saveDraft('First');
online = true;
await flushAll();
const real = server.drafts()[0].id;
online = false;
await saveDraft('Second', id);
expect(server.emails.has(real)).toBe(true);
online = true;
await flushAll();
expect(server.emails.has(real)).toBe(false);
expect((server.drafts()[0].bodyValues as Record<string, { value: string }>)['1'].value).toBe('Second');
});
});
describe('before the flush, with the server back', () => {
it('answers a read of the stand-in from the device, not the server', async () => {
online = false;
const id = await saveDraft('Text');
online = true;
wire.length = 0;
expect((await client.getEmail(id))?.bodyValues?.['1']?.value).toBe('Text');
expect(wire.filter(([name]) => name === 'Email/get')).toEqual([]);
});
it('replaces the stand-in with a save that goes straight to the server', async () => {
online = false;
const id = await saveDraft('Offline');
online = true;
const real = await saveDraft('Online', id);
expect(real).not.toMatch(/^local-/);
await flushAll();
expect(server.drafts().map((email) => email.id)).toEqual([real]);
expect(await engine.areaCall('drafts.list', {})).toEqual([]);
});
it('uploads files attached offline before the save goes to the server', async () => {
online = false;
const { blobId } = await client.uploadBlob(new File(['hello'], 'note.txt', { type: 'text/plain' }));
expect(blobId).toMatch(/^bulwark-local-/);
const id = await saveDraft('With a file', undefined, [{ blobId, name: 'note.txt', type: 'text/plain', size: 5 }]);
online = true;
await saveDraft('With a file, later', id, [{ blobId, name: 'note.txt', type: 'text/plain', size: 5 }]);
const [draft] = server.drafts();
expect(new TextDecoder().decode(server.bytesOf((draft.attachments as Array<{ blobId: string }>)[0].blobId))).toBe('hello');
});
});
describe('a file attached offline', () => {
it('stays readable and downloadable, offline and once the server is back', async () => {
online = false;
const { blobId } = await client.uploadBlob(new File(['hello'], 'note.txt', { type: 'text/plain' }));
expect(await (await client.fetchBlob(blobId, 'note.txt', 'text/plain')).text()).toBe('hello');
online = true;
expect(await (await client.fetchBlob(blobId, 'note.txt', 'text/plain')).text()).toBe('hello');
expect(spy.mock.calls.filter(([url]: [unknown]) => String(url).includes('/d/'))).toEqual([]);
});
it('isn\'t held a second time when its own upload hits an outage', async () => {
const holdUpload = vi.fn();
const hooks = { skipNetwork: () => false, answer: async () => null, record: () => {}, noteNetwork: () => {}, serverGone: async () => true, blob: async () => null, recordBlob: () => {}, holdUpload };
setOfflineHooks(hooks as never);
online = false;
await expect(client.uploadBlob(new File(['x'], 'x.txt'), { noHold: true })).rejects.toThrow();
expect(holdUpload).not.toHaveBeenCalled();
});
});
describe('an open composer\'s attachment after a flush', () => {
const FILE = { name: 'note.txt', type: 'text/plain', size: 5 };
/** A draft with a file on the server, edited offline with its part, and flushed: that part is dead now. */
async function flushedWithOldPart() {
const { blobId: uploaded } = await client.uploadBlob(new File(['hello'], 'note.txt', { type: 'text/plain' }));
const first = await saveDraft('Online', undefined, [{ ...FILE, blobId: uploaded }]);
const part = (server.emails.get(first)?.attachments as Array<{ blobId: string }>)[0].blobId;
// The upload's id would still work: only the part is left in the composer.
server.blobs.delete(uploaded);
online = false;
const standIn = await saveDraft('Offline', first, [{ ...FILE, blobId: part }]);
online = true;
await flushAll();
expect(server.emails.has(first)).toBe(false);
expect(server.bytesOf(part)).toBeUndefined();
return { standIn, part };
}
const fileOnServer = () => new TextDecoder().decode(server.bytesOf((server.drafts()[0].attachments as Array<{ blobId: string }>)[0].blobId));
it('survives the next save, which reads the real draft through the map', async () => {
const { standIn, part } = await flushedWithOldPart();
const saved = await saveDraft('Later', standIn, [{ ...FILE, blobId: part }]);
expect(server.drafts().map((email) => email.id)).toEqual([saved]);
expect(fileOnServer()).toBe('hello');
});
it('survives the next send', async () => {
const { standIn, part } = await flushedWithOldPart();
const result = await client.sendEmail(['b@example.org'], 'Subject', 'Text', undefined, undefined, undefined, 'a@example.org', standIn, undefined, undefined, [{ ...FILE, blobId: part }]);
expect(result.emailId).toBeTruthy();
expect(server.drafts()).toEqual([]);
expect((server.emails.get(result.emailId as string)?.attachments as unknown[])).toHaveLength(1);
});
it('retries once when the server says a part is gone under a real id too', async () => {
const { part } = await flushedWithOldPart();
const real = server.drafts()[0].id;
const saved = await saveDraft('Later', real, [{ ...FILE, blobId: part }]);
expect(server.drafts().map((email) => email.id)).toEqual([saved]);
expect(fileOnServer()).toBe('hello');
});
it('retries a send that says a part is gone under a real id', async () => {
const { part } = await flushedWithOldPart();
const real = server.drafts()[0].id;
const result = await client.sendEmail(['b@example.org'], 'Subject', 'Text', undefined, undefined, undefined, 'a@example.org', real, undefined, undefined, [{ ...FILE, blobId: part }]);
expect(result.emailId).toBeTruthy();
});
it('still fails when no part of that name and size is there', async () => {
const { standIn, part } = await flushedWithOldPart();
await expect(saveDraft('Later', standIn, [{ name: 'other.txt', type: 'text/plain', size: 5, blobId: part }])).rejects.toThrow(/blobNotFound/);
});
it('asks again at most once', async () => {
const { part } = await flushedWithOldPart();
const real = server.drafts()[0].id;
server.refuseCreate = { type: 'blobNotFound' };
wire.length = 0;
await expect(saveDraft('Later', real, [{ ...FILE, blobId: part }])).rejects.toThrow(/blobNotFound/);
expect(wire.filter(([name, args]) => name === 'Email/set' && args.create).length).toBeLessThanOrEqual(2);
});
});
describe('an online send of a draft still on the device', () => {
it('drops the draft here and still removes the submission record on the server', async () => {
online = false;
const id = await saveDraft('Only here');
online = true;
const result = await client.sendEmail(['b@example.org'], 'Subject', 'Text', undefined, undefined, undefined, 'a@example.org', id);
expect(result.emailId).toBeTruthy();
expect(await engine.areaCall('drafts.list', {})).toEqual([]);
expect(server.submissionsDestroyed()).toHaveLength(1);
});
});