diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index dbfd8e869551e..33c8f5dd7f624 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -306,6 +306,17 @@ describe(AlbumService.name, () => { expect(albumMock.update).not.toHaveBeenCalled(); }); + it('should throw an error if the userId is the ownerId', async () => { + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); + albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); + await expect( + sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { + albumUsers: [{ userId: userStub.user1.id }], + }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(albumMock.update).not.toHaveBeenCalled(); + }); + it('should add valid shared users', async () => { accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithAdmin)); @@ -415,6 +426,19 @@ describe(AlbumService.name, () => { }); }); + describe('updateUser', () => { + it('should update user role', async () => { + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); + await sut.updateUser(authStub.user1, albumStub.sharedWithAdmin.id, userStub.admin.id, { + role: AlbumUserRole.EDITOR, + }); + expect(albumUserMock.update).toHaveBeenCalledWith( + { albumId: albumStub.sharedWithAdmin.id, userId: userStub.admin.id }, + { role: AlbumUserRole.EDITOR }, + ); + }); + }); + describe('getAlbumInfo', () => { it('should get a shared album', async () => { albumMock.getById.mockResolvedValue(albumStub.oneAsset); diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 3b067eb3ceda8..c269739935e01 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -1,10 +1,15 @@ -import { BadRequestException, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { + BadRequestException, + InternalServerErrorException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; import { Stats } from 'node:fs'; import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto'; -import { AssetMediaCreateDto, AssetMediaReplaceDto, UploadFieldName } from 'src/dtos/asset-media.dto'; +import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto'; import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity'; -import { AssetStatus, AssetType, CacheControl } from 'src/enum'; +import { AssetFileType, AssetStatus, AssetType, CacheControl } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; @@ -14,6 +19,7 @@ import { ImmichFileResponse } from 'src/utils/file'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { fileStub } from 'test/fixtures/file.stub'; +import { userStub } from 'test/fixtures/user.stub'; import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { newTestService } from 'test/utils'; import { QueryFailedError } from 'typeorm'; @@ -194,6 +200,10 @@ describe(AssetMediaService.name, () => { }); describe('getUploadAssetIdByChecksum', () => { + it('should return if checksum is undefined', async () => { + await expect(sut.getUploadAssetIdByChecksum(authStub.admin)).resolves.toBe(undefined); + }); + it('should handle a non-existent asset', async () => { await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toBeUndefined(); expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); @@ -295,6 +305,35 @@ describe(AssetMediaService.name, () => { }); describe('uploadAsset', () => { + it('should throw an error if the quota is exceeded', async () => { + const file = { + uuid: 'random-uuid', + originalPath: 'fake_path/asset_1.jpeg', + mimeType: 'image/jpeg', + checksum: Buffer.from('file hash', 'utf8'), + originalName: 'asset_1.jpeg', + size: 42, + }; + + assetMock.create.mockResolvedValue(assetEntity); + + await expect( + sut.uploadAsset( + { ...authStub.admin, user: { ...authStub.admin.user, quotaSizeInBytes: 42, quotaUsageInBytes: 1 } }, + createDto, + file, + ), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(assetMock.create).not.toHaveBeenCalled(); + expect(userMock.updateUsage).not.toHaveBeenCalledWith(authStub.user1.user.id, file.size); + expect(storageMock.utimes).not.toHaveBeenCalledWith( + file.originalPath, + expect.any(Date), + new Date(createDto.fileModifiedAt), + ); + }); + it('should handle a file upload', async () => { const file = { uuid: 'random-uuid', @@ -348,6 +387,31 @@ describe(AssetMediaService.name, () => { expect(userMock.updateUsage).not.toHaveBeenCalled(); }); + it('should throw an error if the duplicate could not be found by checksum', async () => { + const file = { + uuid: 'random-uuid', + originalPath: 'fake_path/asset_1.jpeg', + mimeType: 'image/jpeg', + checksum: Buffer.from('file hash', 'utf8'), + originalName: 'asset_1.jpeg', + size: 0, + }; + const error = new QueryFailedError('', [], new Error('unique key violation')); + (error as any).constraint = ASSET_CHECKSUM_CONSTRAINT; + + assetMock.create.mockRejectedValue(error); + + await expect(sut.uploadAsset(authStub.user1, createDto, file)).rejects.toBeInstanceOf( + InternalServerErrorException, + ); + + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.DELETE_FILES, + data: { files: ['fake_path/asset_1.jpeg', undefined] }, + }); + expect(userMock.updateUsage).not.toHaveBeenCalled(); + }); + it('should handle a live photo', async () => { assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); assetMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); @@ -385,6 +449,23 @@ describe(AssetMediaService.name, () => { expect(assetMock.getById).toHaveBeenCalledWith('live-photo-motion-asset'); expect(assetMock.update).toHaveBeenCalledWith({ id: 'live-photo-motion-asset', isVisible: false }); }); + + it('should handle a sidecar file', async () => { + assetMock.getById.mockResolvedValueOnce(assetStub.image); + assetMock.create.mockResolvedValueOnce(assetStub.image); + + await expect(sut.uploadAsset(authStub.user1, createDto, fileStub.photo, fileStub.photoSidecar)).resolves.toEqual({ + status: AssetMediaStatus.CREATED, + id: assetStub.image.id, + }); + + expect(storageMock.utimes).toHaveBeenCalledWith( + fileStub.photoSidecar.originalPath, + expect.any(Date), + new Date(createDto.fileModifiedAt), + ); + expect(assetMock.update).not.toHaveBeenCalled(); + }); }); describe('downloadOriginal', () => { @@ -419,6 +500,170 @@ describe(AssetMediaService.name, () => { }); }); + describe('viewThumbnail', () => { + it('should require asset.view permissions', async () => { + await expect(sut.viewThumbnail(authStub.admin, 'id', {})).rejects.toBeInstanceOf(BadRequestException); + + expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + }); + + it('should throw an error if the asset does not exist', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + assetMock.getById.mockResolvedValue(null); + + await expect( + sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('should throw an error if the requested thumbnail file does not exist', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + assetMock.getById.mockResolvedValue({ ...assetStub.image, files: [] }); + + await expect( + sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('should throw an error if the requested preview file does not exist', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + assetMock.getById.mockResolvedValue({ + ...assetStub.image, + files: [ + { + assetId: assetStub.image.id, + createdAt: assetStub.image.fileCreatedAt, + id: '42', + path: '/path/to/preview', + type: AssetFileType.THUMBNAIL, + updatedAt: new Date(), + }, + ], + }); + await expect( + sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('should fall back to preview if the requested thumbnail file does not exist', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + assetMock.getById.mockResolvedValue({ + ...assetStub.image, + files: [ + { + assetId: assetStub.image.id, + createdAt: assetStub.image.fileCreatedAt, + id: '42', + path: '/path/to/preview.jpg', + type: AssetFileType.PREVIEW, + updatedAt: new Date(), + }, + ], + }); + + await expect( + sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }), + ).resolves.toEqual( + new ImmichFileResponse({ + path: '/path/to/preview.jpg', + cacheControl: CacheControl.PRIVATE_WITH_CACHE, + contentType: 'image/jpeg', + }), + ); + }); + + it('should get preview file', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + assetMock.getById.mockResolvedValue({ ...assetStub.image }); + await expect( + sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }), + ).resolves.toEqual( + new ImmichFileResponse({ + path: assetStub.image.files[0].path, + cacheControl: CacheControl.PRIVATE_WITH_CACHE, + contentType: 'image/jpeg', + }), + ); + }); + + it('should get thumbnail file', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + assetMock.getById.mockResolvedValue({ ...assetStub.image }); + await expect( + sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }), + ).resolves.toEqual( + new ImmichFileResponse({ + path: assetStub.image.files[1].path, + cacheControl: CacheControl.PRIVATE_WITH_CACHE, + contentType: 'application/octet-stream', + }), + ); + }); + }); + + describe('playbackVideo', () => { + it('should require asset.view permissions', async () => { + await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(BadRequestException); + + expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + }); + + it('should throw an error if the asset does not exist', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + assetMock.getById.mockResolvedValue(null); + + await expect(sut.playbackVideo(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(NotFoundException); + }); + + it('should throw an error if the asset is not a video', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + assetMock.getById.mockResolvedValue(assetStub.image); + + await expect(sut.playbackVideo(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should return the encoded video path if available', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.hasEncodedVideo.id])); + assetMock.getById.mockResolvedValue(assetStub.hasEncodedVideo); + + await expect(sut.playbackVideo(authStub.admin, assetStub.hasEncodedVideo.id)).resolves.toEqual( + new ImmichFileResponse({ + path: assetStub.hasEncodedVideo.encodedVideoPath!, + cacheControl: CacheControl.PRIVATE_WITH_CACHE, + contentType: 'video/mp4', + }), + ); + }); + + it('should fall back to the original path', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.video.id])); + assetMock.getById.mockResolvedValue(assetStub.video); + + await expect(sut.playbackVideo(authStub.admin, assetStub.video.id)).resolves.toEqual( + new ImmichFileResponse({ + path: assetStub.video.originalPath, + cacheControl: CacheControl.PRIVATE_WITH_CACHE, + contentType: 'application/octet-stream', + }), + ); + }); + }); + + describe('checkExistingAssets', () => { + it('should get existing asset ids', async () => { + assetMock.getByDeviceIds.mockResolvedValue(['42']); + await expect( + sut.checkExistingAssets(authStub.admin, { deviceId: '420', deviceAssetIds: ['69'] }), + ).resolves.toEqual({ existingIds: ['42'] }); + + expect(assetMock.getByDeviceIds).toHaveBeenCalledWith(userStub.admin.id, '420', ['69']); + }); + }); + describe('replaceAsset', () => { it('should error when update photo does not exist', async () => { assetMock.getById.mockResolvedValueOnce(null); @@ -601,5 +846,37 @@ describe(AssetMediaService.name, () => { expect(assetMock.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]); }); + + it('should return non-duplicates as well', async () => { + const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex'); + const file2 = Buffer.from('53be335e99f18a66ff12e9a901c7a6171dd76573', 'hex'); + + assetMock.getByChecksums.mockResolvedValue([{ id: 'asset-1', checksum: file1 } as AssetEntity]); + + await expect( + sut.bulkUploadCheck(authStub.admin, { + assets: [ + { id: '1', checksum: file1.toString('hex') }, + { id: '2', checksum: file2.toString('base64') }, + ], + }), + ).resolves.toEqual({ + results: [ + { + id: '1', + assetId: 'asset-1', + action: AssetUploadAction.REJECT, + reason: AssetRejectReason.DUPLICATE, + isTrashed: false, + }, + { + id: '2', + action: AssetUploadAction.ACCEPT, + }, + ], + }); + + expect(assetMock.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]); + }); }); }); diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 60234b51ef86e..b320c32a213ae 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -185,9 +185,6 @@ export class AssetMediaService extends BaseService { await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_DOWNLOAD, ids: [id] }); const asset = await this.findOrFail(id); - if (!asset) { - throw new NotFoundException('Asset does not exist'); - } return new ImmichFileResponse({ path: asset.originalPath, @@ -223,9 +220,6 @@ export class AssetMediaService extends BaseService { await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_VIEW, ids: [id] }); const asset = await this.findOrFail(id); - if (!asset) { - throw new NotFoundException('Asset does not exist'); - } if (asset.type !== AssetType.VIDEO) { throw new BadRequestException('Asset is not a video');