mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-22 20:41:50 +08:00
feat: multipart blob sync support (#14138)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Flexible blob uploads: GRAPHQL, presigned, and multipart flows with
per‑part URLs, abort/complete operations, presigned proxy endpoints, and
nightly cleanup of expired pending uploads.
* **API / Schema**
* GraphQL additions: new types, mutations, enum and error to manage
upload lifecycle (create, complete, abort, get part URL).
* **Database**
* New blob status enum and columns (status, upload_id); listing now
defaults to completed blobs.
* **Localization**
* Added user-facing message: "Blob is invalid."
* **Tests**
* Expanded unit and end‑to‑end coverage for upload flows, proxy
behavior, multipart and provider integrations.
<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
abortBlobUploadMutation,
|
||||
BlobUploadMethod,
|
||||
completeBlobUploadMutation,
|
||||
createBlobUploadMutation,
|
||||
getBlobUploadPartUrlMutation,
|
||||
setBlobMutation,
|
||||
workspaceBlobQuotaQuery,
|
||||
} from '@affine/graphql';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { CloudBlobStorage } from '../impls/cloud/blob';
|
||||
|
||||
const quotaResponse = {
|
||||
workspace: {
|
||||
quota: {
|
||||
humanReadable: {
|
||||
blobLimit: '1 MB',
|
||||
},
|
||||
blobLimit: 1024 * 1024,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function createStorage() {
|
||||
return new CloudBlobStorage({
|
||||
serverBaseUrl: 'https://example.com',
|
||||
id: 'workspace-1',
|
||||
});
|
||||
}
|
||||
|
||||
test('uses graphql upload when server returns GRAPHQL method', async () => {
|
||||
const storage = createStorage();
|
||||
const gqlMock = vi.fn(async ({ query }) => {
|
||||
if (query === workspaceBlobQuotaQuery) {
|
||||
return quotaResponse;
|
||||
}
|
||||
if (query === createBlobUploadMutation) {
|
||||
return {
|
||||
createBlobUpload: {
|
||||
method: BlobUploadMethod.GRAPHQL,
|
||||
blobKey: 'blob-key',
|
||||
alreadyUploaded: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (query === setBlobMutation) {
|
||||
return { setBlob: 'blob-key' };
|
||||
}
|
||||
throw new Error('Unexpected query');
|
||||
});
|
||||
|
||||
(storage.connection as any).gql = gqlMock;
|
||||
|
||||
await storage.set({
|
||||
key: 'blob-key',
|
||||
data: new Uint8Array([1, 2, 3]),
|
||||
mime: 'text/plain',
|
||||
});
|
||||
|
||||
const queries = gqlMock.mock.calls.map(call => call[0].query);
|
||||
expect(queries).toContain(createBlobUploadMutation);
|
||||
expect(queries).toContain(setBlobMutation);
|
||||
});
|
||||
|
||||
test('falls back to graphql when presigned upload fails', async () => {
|
||||
const storage = createStorage();
|
||||
const gqlMock = vi.fn(async ({ query }) => {
|
||||
if (query === workspaceBlobQuotaQuery) {
|
||||
return quotaResponse;
|
||||
}
|
||||
if (query === createBlobUploadMutation) {
|
||||
return {
|
||||
createBlobUpload: {
|
||||
method: BlobUploadMethod.PRESIGNED,
|
||||
blobKey: 'blob-key',
|
||||
alreadyUploaded: false,
|
||||
uploadUrl: 'https://upload.example.com/blob',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (query === setBlobMutation) {
|
||||
return { setBlob: 'blob-key' };
|
||||
}
|
||||
if (query === completeBlobUploadMutation) {
|
||||
return { completeBlobUpload: 'blob-key' };
|
||||
}
|
||||
throw new Error('Unexpected query');
|
||||
});
|
||||
|
||||
(storage.connection as any).gql = gqlMock;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response('', { status: 500 }))
|
||||
);
|
||||
|
||||
await storage.set({
|
||||
key: 'blob-key',
|
||||
data: new Uint8Array([1, 2, 3]),
|
||||
mime: 'text/plain',
|
||||
});
|
||||
|
||||
const queries = gqlMock.mock.calls.map(call => call[0].query);
|
||||
expect(queries).toContain(setBlobMutation);
|
||||
expect(queries).not.toContain(completeBlobUploadMutation);
|
||||
});
|
||||
|
||||
test('falls back to graphql and aborts when multipart upload fails', async () => {
|
||||
const storage = createStorage();
|
||||
const gqlMock = vi.fn(async ({ query }) => {
|
||||
if (query === workspaceBlobQuotaQuery) {
|
||||
return quotaResponse;
|
||||
}
|
||||
if (query === createBlobUploadMutation) {
|
||||
return {
|
||||
createBlobUpload: {
|
||||
method: BlobUploadMethod.MULTIPART,
|
||||
blobKey: 'blob-key',
|
||||
alreadyUploaded: false,
|
||||
uploadId: 'upload-1',
|
||||
partSize: 2,
|
||||
uploadedParts: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (query === getBlobUploadPartUrlMutation) {
|
||||
return {
|
||||
getBlobUploadPartUrl: {
|
||||
uploadUrl: 'https://upload.example.com/part',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (query === abortBlobUploadMutation) {
|
||||
return { abortBlobUpload: true };
|
||||
}
|
||||
if (query === setBlobMutation) {
|
||||
return { setBlob: 'blob-key' };
|
||||
}
|
||||
throw new Error('Unexpected query');
|
||||
});
|
||||
|
||||
(storage.connection as any).gql = gqlMock;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response('', { status: 500 }))
|
||||
);
|
||||
|
||||
await storage.set({
|
||||
key: 'blob-key',
|
||||
data: new Uint8Array([1, 2, 3]),
|
||||
mime: 'text/plain',
|
||||
});
|
||||
|
||||
const queries = gqlMock.mock.calls.map(call => call[0].query);
|
||||
expect(queries).toContain(abortBlobUploadMutation);
|
||||
expect(queries).toContain(setBlobMutation);
|
||||
});
|
||||
Reference in New Issue
Block a user