diff --git a/src/services/common/httpStatusCodes.ts b/src/services/common/httpStatusCodes.ts index e3b239441..633eaae55 100644 --- a/src/services/common/httpStatusCodes.ts +++ b/src/services/common/httpStatusCodes.ts @@ -1,3 +1,4 @@ +export const HTTP_BAD_REQUEST = 400; export const HTTP_UNAUTHORIZED = 401; export const HTTP_NOT_FOUND = 404; export const HTTP_CONFLICT = 409; diff --git a/src/services/photos/PhotoUploadService.spec.ts b/src/services/photos/PhotoUploadService.spec.ts index 2a1217540..b33d41ff0 100644 --- a/src/services/photos/PhotoUploadService.spec.ts +++ b/src/services/photos/PhotoUploadService.spec.ts @@ -1,13 +1,14 @@ import * as RNFS from '@dr.pogodin/react-native-fs'; import { EncryptionVersion } from '@internxt/sdk/dist/drive/storage/types'; +import AppError from '@internxt/sdk/dist/shared/types/errors'; import * as MediaLibrary from 'expo-media-library'; import { uploadFile } from 'src/network/upload'; import asyncStorageService from 'src/services/AsyncStorageService'; import { isThumbnailSupported } from 'src/services/common/media/thumbnail.constants'; import { generateThumbnail } from 'src/services/common/media/thumbnail.generation'; import { uploadService } from 'src/services/common/network/upload/upload.service'; -import { PhotoUploadService } from './PhotoUploadService'; import { photoBackupFolders } from './PhotoBackupFolders'; +import { PhotoUploadService } from './PhotoUploadService'; jest.mock('expo-media-library', () => ({ getAssetInfoAsync: jest.fn(), @@ -187,7 +188,10 @@ describe('PhotoUploadService.upload', () => { }); test('when the thumbnail bucket upload throws, then the main upload still returns the drive file uuid', async () => { - mockUploadFile.mockReset().mockResolvedValueOnce('bucket-file-id').mockRejectedValueOnce(new Error('network error')); + mockUploadFile + .mockReset() + .mockResolvedValueOnce('bucket-file-id') + .mockRejectedValueOnce(new Error('network error')); const result = await PhotoUploadService.upload(makeAsset(), DEVICE_ID); @@ -196,7 +200,10 @@ describe('PhotoUploadService.upload', () => { }); test('when the thumbnail bucket upload throws, then the thumbnail temp file is still cleaned up', async () => { - mockUploadFile.mockReset().mockResolvedValueOnce('bucket-file-id').mockRejectedValueOnce(new Error('network error')); + mockUploadFile + .mockReset() + .mockResolvedValueOnce('bucket-file-id') + .mockRejectedValueOnce(new Error('network error')); await PhotoUploadService.upload(makeAsset(), DEVICE_ID); @@ -211,9 +218,7 @@ describe('PhotoUploadService.replace', () => { await PhotoUploadService.replace(asset, 'existing-remote-id', DEVICE_ID); expect(mockGenerateThumbnail).toHaveBeenCalledWith(LOCAL_PATH, 'jpg'); - expect(mockCreateThumbnailEntry).toHaveBeenCalledWith( - expect.objectContaining({ fileUuid: 'existing-remote-id' }), - ); + expect(mockCreateThumbnailEntry).toHaveBeenCalledWith(expect.objectContaining({ fileUuid: 'existing-remote-id' })); }); test('when replacing an asset, then the existing remote file id is returned', async () => { @@ -229,4 +234,30 @@ describe('PhotoUploadService.replace', () => { expect(result).toBe('existing-remote-id'); }); + + test('when the server rejects the replace with a 400, then a new drive entry is created and its uuid is returned', async () => { + mockReplaceFileEntry.mockRejectedValue(new AppError('file can not be replaced', 400)); + mockCreateFileEntry.mockResolvedValue({ uuid: 'new-drive-uuid' }); + + const result = await PhotoUploadService.replace(makeAsset(), 'deleted-remote-id', DEVICE_ID); + + expect(mockCreateFileEntry).toHaveBeenCalledTimes(1); + expect(result).toBe('new-drive-uuid'); + }); + + test('when the server rejects the replace with a 400 and a new entry is created, then the thumbnail is registered against the new file uuid', async () => { + mockReplaceFileEntry.mockRejectedValue(new AppError('file can not be replaced', 400)); + mockCreateFileEntry.mockResolvedValue({ uuid: 'new-drive-uuid' }); + + await PhotoUploadService.replace(makeAsset(), 'deleted-remote-id', DEVICE_ID); + + expect(mockCreateThumbnailEntry).toHaveBeenCalledWith(expect.objectContaining({ fileUuid: 'new-drive-uuid' })); + }); + + test('when the server rejects the replace with a non-400 error, then the error is propagated without creating a new entry', async () => { + mockReplaceFileEntry.mockRejectedValue(new AppError('internal server error', 500)); + + await expect(PhotoUploadService.replace(makeAsset(), 'remote-id', DEVICE_ID)).rejects.toThrow(); + expect(mockCreateFileEntry).not.toHaveBeenCalled(); + }); }); diff --git a/src/services/photos/PhotoUploadService.ts b/src/services/photos/PhotoUploadService.ts index 741a3acb0..8708f1e55 100644 --- a/src/services/photos/PhotoUploadService.ts +++ b/src/services/photos/PhotoUploadService.ts @@ -6,9 +6,11 @@ import { getEnvironmentConfigFromUser } from 'src/lib/network'; import { uploadFile } from 'src/network/upload'; import { constants } from 'src/services/AppService'; import asyncStorageService from 'src/services/AsyncStorageService'; +import { HTTP_BAD_REQUEST } from 'src/services/common/httpStatusCodes'; import { isThumbnailSupported } from 'src/services/common/media/thumbnail.constants'; import { generateThumbnail } from 'src/services/common/media/thumbnail.generation'; import { uploadService } from 'src/services/common/network/upload/upload.service'; +import { logger } from '../common'; import { FileAlreadyExistsError } from './errors'; import { photoBackupFolders } from './PhotoBackupFolders'; import { @@ -121,6 +123,11 @@ const uploadAssetToBucket = async ( }; }; +const isDeletedOrTrashedError = (error: unknown): boolean => { + const status = (error as { status?: unknown })?.status; + return status === HTTP_BAD_REQUEST; +}; + const cleanupTempFile = async (tempPath?: string): Promise => { if (!tempPath) return; await RNFS.unlink(tempPath).catch(() => null); @@ -218,18 +225,44 @@ export const PhotoUploadService = { deviceId: string, onProgress?: (ratio: number) => void, ): Promise { - const { fileId, fileSize, localFilePath, fileExtension, tempPath, credentials } = await uploadAssetToBucket( - asset, - deviceId, - onProgress, - ); + const { + fileId, + fileSize, + localFilePath, + fileExtension, + tempPath, + credentials, + plainName, + bucketId, + folderUuid, + modificationIso, + creationIso, + } = await uploadAssetToBucket(asset, deviceId, onProgress); try { - await uploadService.replaceFileEntry(existingRemoteFileId, { fileId, size: fileSize }); - - await uploadThumbnailForAsset(localFilePath, fileExtension, existingRemoteFileId, credentials); - - return existingRemoteFileId; + try { + await uploadService.replaceFileEntry(existingRemoteFileId, { fileId, size: fileSize }); + await uploadThumbnailForAsset(localFilePath, fileExtension, existingRemoteFileId, credentials); + return existingRemoteFileId; + } catch (replaceError) { + if (!isDeletedOrTrashedError(replaceError)) { + logger.error(`Failed to replace file entry for ${existingRemoteFileId}:`, replaceError); + throw replaceError; + } + const driveFile = await uploadService.createFileEntry({ + fileId, + type: fileExtension, + size: fileSize, + plainName, + bucket: bucketId, + folderUuid, + encryptVersion: EncryptionVersion.Aes03, + modificationTime: modificationIso, + creationTime: creationIso, + }); + await uploadThumbnailForAsset(localFilePath, fileExtension, driveFile.uuid, credentials); + return driveFile.uuid; + } } finally { await cleanupTempFile(tempPath); }