feat: files API reads modified dates, decodes WebDAV names, copies/moves nodes and streams uploads (#700, #869, #563)

This commit is contained in:
Linus Rath
2026-08-29 17:59:44 +02:00
parent 1cef53af5a
commit a0ce9258cd
7 changed files with 533 additions and 84 deletions
+78 -1
View File
@@ -7,6 +7,23 @@ vi.mock('expo-file-system', () => ({
},
}));
// The streaming upload path is mocked as a controllable task so the tests
// can drive progress, completion and cancellation.
const uploadTask = {
uploadAsync: vi.fn(),
cancelAsync: vi.fn(async () => undefined),
};
const createUploadTask = vi.fn((..._args: unknown[]) => uploadTask);
vi.mock('expo-file-system/legacy', () => ({
createUploadTask: (...args: unknown[]) => createUploadTask(...args),
FileSystemUploadType: { BINARY_CONTENT: 0, MULTIPART: 1 },
}));
vi.mock('../../lib/client-cert', () => ({
getClientCertAlias: vi.fn(async () => null),
secureFetch: vi.fn(),
}));
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
@@ -18,7 +35,7 @@ vi.mock('../jmap-client', () => ({
},
}));
import { getDownloadUrl } from '../blob';
import { getDownloadUrl, uploadBlob } from '../blob';
beforeEach(() => {
vi.clearAllMocks();
@@ -49,3 +66,63 @@ describe('blob operations', () => {
});
});
});
describe('uploadBlob', () => {
it('streams the file through a native upload task with auth headers and reports progress', async () => {
let progressCb: ((d: { totalBytesSent: number; totalBytesExpectedToSend: number }) => void) | undefined;
createUploadTask.mockImplementationOnce((...args: unknown[]) => {
progressCb = args[3] as typeof progressCb;
return uploadTask;
});
uploadTask.uploadAsync.mockImplementationOnce(async () => {
progressCb?.({ totalBytesSent: 50, totalBytesExpectedToSend: 100 });
return { status: 201, body: JSON.stringify({ blobId: 'blob-1', size: 100, type: 'image/png' }) };
});
const onProgress = vi.fn();
const result = await uploadBlob('file:///tmp/a.png', 'image/png', { onProgress });
expect(result).toEqual({ blobId: 'blob-1', size: 100, type: 'image/png' });
expect(onProgress).toHaveBeenCalledWith(50, 100);
const [url, uri, options] = createUploadTask.mock.calls[0] as unknown as [string, string, { headers: Record<string, string>; httpMethod: string }];
expect(url).toBe('https://mail.example.com/upload/acc-1/');
expect(uri).toBe('file:///tmp/a.png');
expect(options.httpMethod).toBe('POST');
expect(options.headers).toEqual({ 'Content-Type': 'image/png', Authorization: 'Basic dXNlcjpwYXNz' });
});
it('accepts the per-account nested response shape', async () => {
uploadTask.uploadAsync.mockResolvedValueOnce({
status: 200,
body: JSON.stringify({ 'acc-1': { blobId: 'blob-2', size: 7, type: 'text/plain' } }),
});
const result = await uploadBlob('file:///tmp/a.txt', 'text/plain');
expect(result.blobId).toBe('blob-2');
});
it('surfaces HTTP failures with the status code', async () => {
uploadTask.uploadAsync.mockResolvedValueOnce({ status: 413, body: 'too large' });
await expect(uploadBlob('file:///tmp/a.bin', 'application/octet-stream')).rejects.toThrow(/413/);
});
it('cancels the native task when the signal aborts', async () => {
const controller = new AbortController();
uploadTask.uploadAsync.mockImplementationOnce(async () => {
controller.abort();
return null;
});
await expect(
uploadBlob('file:///tmp/a.bin', 'application/octet-stream', { signal: controller.signal }),
).rejects.toMatchObject({ name: 'AbortError' });
expect(uploadTask.cancelAsync).toHaveBeenCalled();
});
it('rejects immediately when the signal is already aborted', async () => {
const controller = new AbortController();
controller.abort();
await expect(
uploadBlob('file:///tmp/a.bin', 'application/octet-stream', { signal: controller.signal }),
).rejects.toMatchObject({ name: 'AbortError' });
expect(createUploadTask).not.toHaveBeenCalled();
});
});
+108 -1
View File
@@ -5,6 +5,8 @@ vi.mock('../jmap-client', () => ({
accountId: 'acc-1',
request: vi.fn(),
hasCapability: vi.fn(),
hasAccountCapability: vi.fn(() => false),
getMaxSizeUpload: vi.fn(() => 0),
currentSession: null as unknown,
},
}));
@@ -21,14 +23,19 @@ vi.mock('../blob', () => ({
import { jmapClient } from '../jmap-client';
import { CAPABILITIES } from '../types';
import {
accountSupportsFiles,
copyFileNode,
createFolder,
deleteFileNodes,
getAllFileNodes,
getAllFileNodesAcrossAccounts,
getFileNodeDownloadUrl,
getPrincipals,
isCrossAccountId,
isFolder,
moveFileNode,
setFileNodeShare,
supportsSharing,
} from '../files';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
@@ -41,7 +48,10 @@ function setSession(session: unknown) {
beforeEach(() => {
vi.clearAllMocks();
mockHasCapability.mockImplementation(
(urn: string) => urn === CAPABILITIES.FILES || urn === CAPABILITIES.PRINCIPALS,
(urn: string) =>
urn === CAPABILITIES.FILES ||
urn === CAPABILITIES.PRINCIPALS ||
urn === CAPABILITIES.PRINCIPALS_OWNER,
);
setSession({
primaryAccounts: { [CAPABILITIES.FILES]: 'acc-1' },
@@ -250,3 +260,100 @@ describe('getPrincipals', () => {
expect(mockRequest).not.toHaveBeenCalled();
});
});
describe('decodeFileNodeName at the API boundary (#869)', () => {
it('decodes percent-encoded names from getAllFileNodes and the cross-account list', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/get', {
list: [
{ id: 'n1', name: 'Spares%20Catalog', blobId: null },
{ id: 'n2', name: '100% done.txt', blobId: 'b2' },
],
}, '0']],
});
const own = await getAllFileNodes();
expect(own.map((n) => n.name)).toEqual(['Spares Catalog', '100% done.txt']);
const all = await getAllFileNodesAcrossAccounts();
expect(all.map((n) => n.name)).toEqual(['Spares Catalog', '100% done.txt']);
});
it('requests the real "modified" property, not "updated" (#700)', async () => {
mockRequest.mockResolvedValue({ methodResponses: [['FileNode/get', { list: [] }, '0']] });
await getAllFileNodes();
const [, args] = mockRequest.mock.calls[0][0][0];
expect(args.properties).toContain('modified');
expect(args.properties).not.toContain('updated');
});
});
describe('copyFileNode', () => {
it('creates a new node that reuses the blob instead of re-uploading', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['FileNode/set', { created: { 'new-file': { id: 'copy-1' } } }, '0']],
});
const copy = await copyFileNode(
{ id: 'f1', name: 'report.pdf', type: 'application/pdf', blobId: 'blob-1', size: 10, parentId: 'dir-1' },
'dir-1',
'report (1).pdf',
);
const [method, args] = mockRequest.mock.calls[0][0][0];
expect(method).toBe('FileNode/set');
expect(args.create['new-file']).toEqual({
name: 'report (1).pdf', type: 'application/pdf', blobId: 'blob-1', size: 10, parentId: 'dir-1',
});
expect(copy.id).toBe('copy-1');
});
it('refuses to duplicate folders', async () => {
await expect(copyFileNode({ id: 'd', name: 'Docs', type: '', blobId: null }, null)).rejects.toThrow();
expect(mockRequest).not.toHaveBeenCalled();
});
});
describe('moveFileNode', () => {
it('sends an explicit null parentId when moving to the root', async () => {
mockRequest.mockResolvedValue({ methodResponses: [['FileNode/set', { updated: { f1: null } }, '0']] });
await moveFileNode('f1', null);
const [, args] = mockRequest.mock.calls[0][0][0];
expect(args.update.f1).toEqual({ parentId: null });
});
});
describe('accountSupportsFiles (#563)', () => {
const caps = { [CAPABILITIES.FILES]: {} };
it('requires the account capability on personal accounts', () => {
expect(accountSupportsFiles({ name: 'me', isPersonal: true, isReadOnly: false }, caps)).toBe(false);
expect(accountSupportsFiles(
{ name: 'me', isPersonal: true, isReadOnly: false, accountCapabilities: { [CAPABILITIES.FILES]: {} } },
caps,
)).toBe(true);
});
it('treats non-personal accounts as capable and needs the session capability', () => {
expect(accountSupportsFiles({ name: 'grp', isPersonal: false, isReadOnly: false }, caps)).toBe(true);
expect(accountSupportsFiles({ name: 'grp', isPersonal: false, isReadOnly: false }, {})).toBe(false);
expect(accountSupportsFiles(undefined, caps)).toBe(false);
});
});
describe('supportsSharing', () => {
it('accepts principals:owner from the session or the account capabilities', () => {
const mockHasAccountCapability = jmapClient.hasAccountCapability as ReturnType<typeof vi.fn>;
mockHasCapability.mockImplementation((urn: string) => urn === CAPABILITIES.FILES);
mockHasAccountCapability.mockReturnValue(false);
expect(supportsSharing()).toBe(false);
mockHasAccountCapability.mockImplementation((urn: string) => urn === CAPABILITIES.PRINCIPALS_OWNER);
expect(supportsSharing()).toBe(true);
mockHasAccountCapability.mockReturnValue(false);
mockHasCapability.mockImplementation(
(urn: string) => urn === CAPABILITIES.FILES || urn === CAPABILITIES.PRINCIPALS_OWNER,
);
expect(supportsSharing()).toBe(true);
});
});