mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 08:07:29 +00:00
feat: files API reads modified dates, decodes WebDAV names, copies/moves nodes and streams uploads (#700, #869, #563)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user