Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@
},
"dependencies": {
"@internxt/drive-desktop-core": "0.1.7",
"@internxt/inxt-js": "^3.2.2",
"@internxt/inxt-js": "^3.3.1",
"@internxt/scan": "^1.0.7",
"@internxt/sdk": "1.17.4",
"async": "^3.2.4",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Environment } from '@internxt/inxt-js';
import { UploadOptions } from '@internxt/inxt-js/build/lib/core';
import { Readable } from 'stream';
import { deepMocked } from '../../../../../tests/vitest/utils.helper';
import { uploadContentToEnvironment } from './upload-content-to-environment';
Expand All @@ -13,38 +12,39 @@ vi.mock('../../../../infra/local-file-system/safe-access', () => ({
}));

describe('upload-content-to-environment', () => {
type UploadOptions = {
source: NodeJS.ReadableStream;
fileSize: number;
progressCallback: (progress: number) => void;
abortSignal?: AbortSignal;
};

const createReadStreamMock = deepMocked(fs.createReadStream);
const safeAccessMock = vi.mocked(safeAccessModule.safeAccess);

const SMALL_SIZE = 1024;
const LARGE_SIZE = 200 * 1024 * 1024; // > 100MB threshold

let environment: Environment;
let abortController: AbortController;
let fakeStream: Readable;
let capturedOpts: UploadOptions;

function makeActionState() {
return { stop: vi.fn() };
}

function makeUploadFn(actionState = makeActionState()) {
return vi.fn((_bucket: string, opts: UploadOptions) => {
capturedOpts = opts;
return actionState;
});
}
let capturedOpts: UploadOptions | null;
let uploadMock: ReturnType<typeof vi.fn<(bucket: string, opts: UploadOptions) => Promise<string>>>;

beforeEach(() => {
abortController = new AbortController();
capturedOpts = undefined as unknown as UploadOptions;
capturedOpts = null;
fakeStream = Object.assign(new Readable({ read() {} }), { close: vi.fn(), destroy: vi.fn() });
createReadStreamMock.mockReturnValue(fakeStream as ReturnType<typeof fs.createReadStream>);
safeAccessMock.mockResolvedValue({ data: undefined });

uploadMock = vi.fn(async (_bucket: string, opts: UploadOptions) => {
capturedOpts = opts;
return 'default-contents-id';
});

environment = {
upload: makeUploadFn(),
uploadMultipartFile: makeUploadFn(),
upload: uploadMock,
uploadMultipartFile: vi.fn(),
} as unknown as Environment;
});

Expand All @@ -61,102 +61,81 @@ describe('upload-content-to-environment', () => {
async function startUpload(size = SMALL_SIZE) {
const promise = callUpload(size);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
return { promise };
}

function triggerFinished(err: Error | null, contentsId: string | null) {
capturedOpts.finishedCallback(err, contentsId);
}

it('should resolve with contentsId on successful upload', async () => {
const contentsId = 'abc123';
const { promise } = await startUpload();
expect(capturedOpts).toBeDefined();
triggerFinished(null, contentsId);
uploadMock.mockResolvedValue(contentsId);

const result = await promise;
const result = await callUpload();

expect(result.data).toBe(contentsId);
expect(result.error).toBeUndefined();
});

it('should use upload for files below multipart threshold', async () => {
const { promise } = await startUpload(SMALL_SIZE);
triggerFinished(null, 'id');

await promise;

expect(environment.upload).toHaveBeenCalled();
expect(environment.uploadMultipartFile).not.toHaveBeenCalled();
});

it('should use uploadMultipartFile for files above multipart threshold', async () => {
const { promise } = await startUpload(LARGE_SIZE);
triggerFinished(null, 'id');
it('should call upload with file size and abortSignal', async () => {
uploadMock.mockImplementation(async (_bucket: string, opts: UploadOptions) => {
capturedOpts = opts;
return 'id';
});

await promise;
await callUpload(SMALL_SIZE);

expect(environment.uploadMultipartFile).toHaveBeenCalled();
expect(environment.upload).not.toHaveBeenCalled();
expect(capturedOpts?.fileSize).toBe(SMALL_SIZE);
expect(capturedOpts?.abortSignal).toBe(abortController.signal);
expect(uploadMock).toHaveBeenCalled();
});

it('should return NOT_ENOUGH_SPACE error when upload fails with "Max space used"', async () => {
const { promise } = await startUpload();
triggerFinished(new Error('Max space used'), null);
uploadMock.mockRejectedValue(new Error('Max space used'));

const result = await promise;
const result = await callUpload();

expect(result.error?.cause).toBe('NOT_ENOUGH_SPACE');
});

it('should return RATE_LIMITED error on 429 with retry_after from message', async () => {
const { promise } = await startUpload();
const err = Object.assign(new Error(JSON.stringify({ retry_after: 10 })), { status: 429 });
triggerFinished(err, null);
uploadMock.mockRejectedValue(err);

const result = await promise;
const result = await callUpload();

expect(result.error?.cause).toBe('RATE_LIMITED');
expect(result.error?.message).toBe('10000');
});

it('should return RATE_LIMITED with default delay when retry_after is missing', async () => {
const { promise } = await startUpload();
const err = Object.assign(new Error('{}'), { status: 429 });
triggerFinished(err, null);
uploadMock.mockRejectedValue(err);

const result = await promise;
const result = await callUpload();

expect(result.error?.cause).toBe('RATE_LIMITED');
expect(result.error?.message).toBe('30000');
});

it('should return INTERNAL_SERVER_ERROR on 500+ errors', async () => {
const { promise } = await startUpload();
const err = Object.assign(new Error('Server error'), { status: 500 });
triggerFinished(err, null);
uploadMock.mockRejectedValue(err);

const result = await promise;
const result = await callUpload();

expect(result.error?.cause).toBe('INTERNAL_SERVER_ERROR');
});

it('should return UNKNOWN error for generic errors', async () => {
const { promise } = await startUpload();
triggerFinished(new Error('Something went wrong'), null);
uploadMock.mockRejectedValue(new Error('Something went wrong'));

const result = await promise;
const result = await callUpload();

expect(result.error?.cause).toBe('UNKNOWN');
});

it('should return UNKNOWN error when contentsId is null on success', async () => {
const { promise } = await startUpload();
triggerFinished(null, null);
it('should return UNKNOWN error when contentsId is empty on success', async () => {
uploadMock.mockResolvedValue('');

const result = await promise;
const result = await callUpload();

expect(result.error?.cause).toBe('UNKNOWN');
});
Expand All @@ -179,8 +158,7 @@ describe('upload-content-to-environment', () => {

expect(result.error).toBe(accessError);
expect(createReadStreamMock).not.toHaveBeenCalled();
expect(environment.upload).not.toHaveBeenCalled();
expect(environment.uploadMultipartFile).not.toHaveBeenCalled();
expect(uploadMock).not.toHaveBeenCalled();
});

it('should return ACTION_NOT_PERMITTED when createReadStream throws EACCES', async () => {
Expand All @@ -193,35 +171,49 @@ describe('upload-content-to-environment', () => {
expect(result.error?.cause).toBe('ACTION_NOT_PERMITTED');
expect(safeAccessMock).toHaveBeenCalledWith({ absolutePath: '/some/file.txt' });
expect(createReadStreamMock).toHaveBeenCalledWith('/some/file.txt');
expect(environment.upload).not.toHaveBeenCalled();
expect(environment.uploadMultipartFile).not.toHaveBeenCalled();
expect(uploadMock).not.toHaveBeenCalled();
expect(result.data).toBeUndefined();
});

it('should return ACTION_NOT_PERMITTED when the read stream emits EACCES', async () => {
uploadMock.mockImplementation(
async (_bucket: string, opts: UploadOptions) =>
await new Promise<string>((_resolve, reject) => {
opts.source.once('error', reject);
}),
);

const { promise } = await startUpload();

fakeStream.emit('error', Object.assign(new Error('permission denied'), { code: 'EACCES' }));
triggerFinished(null, 'contents-id-after-stream-error');

const result = await promise;

expect(result.error?.cause).toBe('ACTION_NOT_PERMITTED');
expect(safeAccessMock).toHaveBeenCalledWith({ absolutePath: '/some/file.txt' });
expect(createReadStreamMock).toHaveBeenCalledWith('/some/file.txt');
expect(environment.upload).toHaveBeenCalledTimes(1);
expect(environment.uploadMultipartFile).not.toHaveBeenCalled();
expect(uploadMock).toHaveBeenCalledTimes(1);
expect(result.data).toBeUndefined();
});

it('should stop the upload and destroy the stream when signal is aborted', async () => {
const actionState = makeActionState();
(environment.upload as unknown as ReturnType<typeof makeUploadFn>) = makeUploadFn(actionState);
it('should abort the upload and destroy the stream when signal is aborted', async () => {
uploadMock.mockImplementation(
async (_bucket: string, opts: UploadOptions) =>
await new Promise<string>((_resolve, reject) => {
opts.abortSignal?.addEventListener(
'abort',
() => {
reject(new Error('aborted'));
},
{ once: true },
);
}),
);

await startUpload();
const { promise } = await startUpload();
abortController.abort();
await promise;

expect(actionState.stop).toHaveBeenCalled();
expect(fakeStream.destroy).toHaveBeenCalled();
});
});
Loading
Loading