diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 0bcb87e2f4..52e4559760 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -9,12 +9,15 @@ import { AssetFile } from 'src/database'; import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto'; import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto'; import { MapAsset } from 'src/dtos/asset-response.dto'; +import { AssetEditAction } from 'src/dtos/editing.dto'; import { AssetFileType, AssetStatus, AssetType, AssetVisibility, CacheControl, JobName } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { AssetMediaService } from 'src/services/asset-media.service'; import { UploadBody } from 'src/types'; import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database'; import { ImmichFileResponse } from 'src/utils/file'; +import { AssetFileFactory } from 'test/factories/asset-file.factory'; +import { AssetFactory } from 'test/factories/asset.factory'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { fileStub } from 'test/fixtures/file.stub'; @@ -470,12 +473,13 @@ describe(AssetMediaService.name, () => { }); it('should handle a sidecar file', async () => { - mocks.asset.getById.mockResolvedValueOnce(assetStub.image); - mocks.asset.create.mockResolvedValueOnce(assetStub.image); + const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).build(); + mocks.asset.getById.mockResolvedValueOnce(asset); + mocks.asset.create.mockResolvedValueOnce(asset); await expect(sut.uploadAsset(authStub.user1, createDto, fileStub.photo, fileStub.photoSidecar)).resolves.toEqual({ status: AssetMediaStatus.CREATED, - id: assetStub.image.id, + id: asset.id, }); expect(mocks.storage.utimes).toHaveBeenCalledWith( @@ -501,13 +505,14 @@ describe(AssetMediaService.name, () => { }); it('should download a file', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - mocks.asset.getForOriginal.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getForOriginal.mockResolvedValue(asset); - await expect(sut.downloadOriginal(authStub.admin, 'asset-1', {})).resolves.toEqual( + await expect(sut.downloadOriginal(authStub.admin, asset.id, {})).resolves.toEqual( new ImmichFileResponse({ - path: '/original/path.jpg', - fileName: 'asset-id.jpg', + path: asset.originalPath, + fileName: asset.originalFileName, contentType: 'image/jpeg', cacheControl: CacheControl.PrivateWithCache, }), @@ -573,28 +578,16 @@ describe(AssetMediaService.name, () => { }); it('should not return the unedited version if requested using a shared link', async () => { - const editedAsset = { - ...assetStub.withCropEdit, - files: [ - ...assetStub.withCropEdit.files, - { - id: 'edited-file', - type: AssetFileType.FullSize, - path: '/uploads/user-id/fullsize/edited.jpg', - isEdited: true, - }, - ], - }; - mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id])); - mocks.asset.getForOriginal.mockResolvedValue({ - ...editedAsset, - editedPath: '/uploads/user-id/fullsize/edited.jpg', - }); + const fullsizeEdited = AssetFileFactory.create({ type: AssetFileType.FullSize, isEdited: true }); + const editedAsset = AssetFactory.from().edit({ action: AssetEditAction.Crop }).file(fullsizeEdited).build(); - await expect(sut.downloadOriginal(authStub.adminSharedLink, 'asset-id', { edited: false })).resolves.toEqual( + mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([editedAsset.id])); + mocks.asset.getForOriginal.mockResolvedValue({ ...editedAsset, editedPath: fullsizeEdited.path }); + + await expect(sut.downloadOriginal(authStub.adminSharedLink, editedAsset.id, { edited: false })).resolves.toEqual( new ImmichFileResponse({ - path: '/uploads/user-id/fullsize/edited.jpg', - fileName: 'asset-id.jpg', + path: fullsizeEdited.path, + fileName: editedAsset.originalFileName, contentType: 'image/jpeg', cacheControl: CacheControl.PrivateWithCache, }), @@ -638,129 +631,118 @@ describe(AssetMediaService.name, () => { }); it('should fall back to preview if the requested thumbnail file does not exist', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - mocks.asset.getForThumbnail.mockResolvedValue({ ...assetStub.image, path: '/path/to/preview.jpg' }); + const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getForThumbnail.mockResolvedValue({ ...asset, path: asset.files[0].path }); - await expect( - sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }), - ).resolves.toEqual( + await expect(sut.viewThumbnail(authStub.admin, asset.id, { size: AssetMediaSize.THUMBNAIL })).resolves.toEqual( new ImmichFileResponse({ - path: '/path/to/preview.jpg', + path: asset.files[0].path, cacheControl: CacheControl.PrivateWithCache, contentType: 'image/jpeg', - fileName: 'asset-id_thumbnail.jpg', + fileName: `IMG_${asset.id}_thumbnail.jpg`, }), ); }); it('should get preview file', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - mocks.asset.getForThumbnail.mockResolvedValue({ ...assetStub.image, path: '/uploads/user-id/thumbs/path.jpg' }); - await expect( - sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }), - ).resolves.toEqual( + const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getForThumbnail.mockResolvedValue({ ...asset, path: asset.files[0].path }); + await expect(sut.viewThumbnail(authStub.admin, asset.id, { size: AssetMediaSize.PREVIEW })).resolves.toEqual( new ImmichFileResponse({ - path: '/uploads/user-id/thumbs/path.jpg', + path: asset.files[0].path, cacheControl: CacheControl.PrivateWithCache, contentType: 'image/jpeg', - fileName: 'asset-id_preview.jpg', + fileName: `IMG_${asset.id}_preview.jpg`, }), ); }); it('should get thumbnail file', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - mocks.asset.getForThumbnail.mockResolvedValue({ ...assetStub.image, path: '/uploads/user-id/webp/path.ext' }); - await expect( - sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }), - ).resolves.toEqual( + const asset = AssetFactory.from() + .file({ type: AssetFileType.Thumbnail, path: '/uploads/user-id/webp/path.ext' }) + .build(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getForThumbnail.mockResolvedValue({ ...asset, path: asset.files[0].path }); + await expect(sut.viewThumbnail(authStub.admin, asset.id, { size: AssetMediaSize.THUMBNAIL })).resolves.toEqual( new ImmichFileResponse({ - path: '/uploads/user-id/webp/path.ext', + path: asset.files[0].path, cacheControl: CacheControl.PrivateWithCache, contentType: 'application/octet-stream', - fileName: 'asset-id_thumbnail.ext', + fileName: `IMG_${asset.id}_thumbnail.ext`, }), ); - expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, false); + expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(asset.id, AssetFileType.Thumbnail, false); }); it('should get original thumbnail by default', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - mocks.asset.getForThumbnail.mockResolvedValue({ - ...assetStub.image, - path: '/uploads/user-id/thumbs/original-thumbnail.jpg', - }); - await expect( - sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }), - ).resolves.toEqual( + const asset = AssetFactory.from().file({ type: AssetFileType.Thumbnail }).build(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getForThumbnail.mockResolvedValue({ ...asset, path: asset.files[0].path }); + await expect(sut.viewThumbnail(authStub.admin, asset.id, { size: AssetMediaSize.THUMBNAIL })).resolves.toEqual( new ImmichFileResponse({ - path: '/uploads/user-id/thumbs/original-thumbnail.jpg', + path: asset.files[0].path, cacheControl: CacheControl.PrivateWithCache, contentType: 'image/jpeg', - fileName: 'asset-id_thumbnail.jpg', + fileName: `IMG_${asset.id}_thumbnail.jpg`, }), ); - expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, false); + expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(asset.id, AssetFileType.Thumbnail, false); }); it('should get edited thumbnail when edited=true', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - mocks.asset.getForThumbnail.mockResolvedValue({ - ...assetStub.image, - path: '/uploads/user-id/thumbs/edited-thumbnail.jpg', - }); + const asset = AssetFactory.from().file({ type: AssetFileType.Thumbnail, isEdited: true }).build(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getForThumbnail.mockResolvedValue({ ...asset, path: asset.files[0].path }); await expect( - sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL, edited: true }), + sut.viewThumbnail(authStub.admin, asset.id, { size: AssetMediaSize.THUMBNAIL, edited: true }), ).resolves.toEqual( new ImmichFileResponse({ - path: '/uploads/user-id/thumbs/edited-thumbnail.jpg', + path: asset.files[0].path, cacheControl: CacheControl.PrivateWithCache, contentType: 'image/jpeg', - fileName: 'asset-id_thumbnail.jpg', + fileName: `IMG_${asset.id}_thumbnail.jpg`, }), ); - expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, true); + expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(asset.id, AssetFileType.Thumbnail, true); }); it('should get original thumbnail when edited=false', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - mocks.asset.getForThumbnail.mockResolvedValue({ - ...assetStub.image, - path: '/uploads/user-id/thumbs/original-thumbnail.jpg', - }); + const asset = AssetFactory.from().file({ type: AssetFileType.Thumbnail }).build(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getForThumbnail.mockResolvedValue({ ...asset, path: asset.files[0].path }); await expect( - sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL, edited: false }), + sut.viewThumbnail(authStub.admin, asset.id, { size: AssetMediaSize.THUMBNAIL, edited: false }), ).resolves.toEqual( new ImmichFileResponse({ - path: '/uploads/user-id/thumbs/original-thumbnail.jpg', + path: asset.files[0].path, cacheControl: CacheControl.PrivateWithCache, contentType: 'image/jpeg', - fileName: 'asset-id_thumbnail.jpg', + fileName: `IMG_${asset.id}_thumbnail.jpg`, }), ); - expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, false); + expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(asset.id, AssetFileType.Thumbnail, false); }); it('should not return the unedited version if requested using a shared link', async () => { - mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id])); - mocks.asset.getForThumbnail.mockResolvedValue({ - ...assetStub.image, - path: '/uploads/user-id/thumbs/edited-thumbnail.jpg', - }); + const asset = AssetFactory.from().file({ type: AssetFileType.Thumbnail }).build(); + mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getForThumbnail.mockResolvedValue({ ...asset, path: asset.files[0].path }); await expect( - sut.viewThumbnail(authStub.adminSharedLink, assetStub.image.id, { + sut.viewThumbnail(authStub.adminSharedLink, asset.id, { size: AssetMediaSize.THUMBNAIL, edited: true, }), ).resolves.toEqual( new ImmichFileResponse({ - path: '/uploads/user-id/thumbs/edited-thumbnail.jpg', + path: asset.files[0].path, cacheControl: CacheControl.PrivateWithCache, contentType: 'image/jpeg', - fileName: 'asset-id_thumbnail.jpg', + fileName: `IMG_${asset.id}_thumbnail.jpg`, }), ); - expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, true); + expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(asset.id, AssetFileType.Thumbnail, true); }); }); @@ -774,18 +756,20 @@ describe(AssetMediaService.name, () => { }); it('should throw an error if the video asset could not be found', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + const asset = AssetFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - await expect(sut.playbackVideo(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(NotFoundException); + await expect(sut.playbackVideo(authStub.admin, asset.id)).rejects.toBeInstanceOf(NotFoundException); }); it('should return the encoded video path if available', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.hasEncodedVideo.id])); - mocks.asset.getForVideo.mockResolvedValue(assetStub.hasEncodedVideo); + const asset = AssetFactory.create({ encodedVideoPath: '/path/to/encoded/video.mp4' }); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getForVideo.mockResolvedValue(asset); - await expect(sut.playbackVideo(authStub.admin, assetStub.hasEncodedVideo.id)).resolves.toEqual( + await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual( new ImmichFileResponse({ - path: assetStub.hasEncodedVideo.encodedVideoPath!, + path: asset.encodedVideoPath!, cacheControl: CacheControl.PrivateWithCache, contentType: 'video/mp4', }), diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index ff4dfa96ff..0b1b78d41a 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -3,7 +3,7 @@ import { DateTime } from 'luxon'; import { MapAsset } from 'src/dtos/asset-response.dto'; import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto'; import { AssetEditAction } from 'src/dtos/editing.dto'; -import { AssetMetadataKey, AssetStatus, AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum'; +import { AssetFileType, AssetMetadataKey, AssetStatus, AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum'; import { AssetStats } from 'src/repositories/asset.repository'; import { AssetService } from 'src/services/asset.service'; import { AssetFactory } from 'test/factories/asset.factory'; @@ -79,7 +79,7 @@ describe(AssetService.name, () => { describe('getRandom', () => { it('should get own random assets', async () => { mocks.partner.getAll.mockResolvedValue([]); - mocks.asset.getRandom.mockResolvedValue([assetStub.image]); + mocks.asset.getRandom.mockResolvedValue([AssetFactory.create()]); await sut.getRandom(authStub.admin, 1); @@ -90,7 +90,7 @@ describe(AssetService.name, () => { const partner = factory.partner({ inTimeline: false }); const auth = factory.auth({ user: { id: partner.sharedWithId } }); - mocks.asset.getRandom.mockResolvedValue([assetStub.image]); + mocks.asset.getRandom.mockResolvedValue([AssetFactory.create()]); mocks.partner.getAll.mockResolvedValue([partner]); await sut.getRandom(auth, 1); @@ -102,7 +102,7 @@ describe(AssetService.name, () => { const partner = factory.partner({ inTimeline: true }); const auth = factory.auth({ user: { id: partner.sharedWithId } }); - mocks.asset.getRandom.mockResolvedValue([assetStub.image]); + mocks.asset.getRandom.mockResolvedValue([AssetFactory.create()]); mocks.partner.getAll.mockResolvedValue([partner]); await sut.getRandom(auth, 1); @@ -113,88 +113,90 @@ describe(AssetService.name, () => { describe('get', () => { it('should allow owner access', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - mocks.asset.getById.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getById.mockResolvedValue(asset); - await sut.get(authStub.admin, assetStub.image.id); + await sut.get(authStub.admin, asset.id); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, - new Set([assetStub.image.id]), + new Set([asset.id]), undefined, ); }); it('should allow shared link access', async () => { - mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id])); - mocks.asset.getById.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getById.mockResolvedValue(asset); - await sut.get(authStub.adminSharedLink, assetStub.image.id); + await sut.get(authStub.adminSharedLink, asset.id); expect(mocks.access.asset.checkSharedLinkAccess).toHaveBeenCalledWith( authStub.adminSharedLink.sharedLink?.id, - new Set([assetStub.image.id]), + new Set([asset.id]), ); }); it('should strip metadata for shared link if exif is disabled', async () => { - mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id])); - mocks.asset.getById.mockResolvedValue(assetStub.image); + const asset = AssetFactory.from().exif({ description: 'foo' }).build(); + mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getById.mockResolvedValue(asset); const result = await sut.get( { ...authStub.adminSharedLink, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } }, - assetStub.image.id, + asset.id, ); expect(result).toEqual(expect.objectContaining({ hasMetadata: false })); expect(result).not.toHaveProperty('exifInfo'); expect(mocks.access.asset.checkSharedLinkAccess).toHaveBeenCalledWith( authStub.adminSharedLink.sharedLink?.id, - new Set([assetStub.image.id]), + new Set([asset.id]), ); }); it('should allow partner sharing access', async () => { - mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - mocks.asset.getById.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getById.mockResolvedValue(asset); - await sut.get(authStub.admin, assetStub.image.id); + await sut.get(authStub.admin, asset.id); - expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith( - authStub.admin.user.id, - new Set([assetStub.image.id]), - ); + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set([asset.id])); }); it('should allow shared album access', async () => { - mocks.access.asset.checkAlbumAccess.mockResolvedValue(new Set([assetStub.image.id])); - mocks.asset.getById.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.access.asset.checkAlbumAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getById.mockResolvedValue(asset); - await sut.get(authStub.admin, assetStub.image.id); + await sut.get(authStub.admin, asset.id); - expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith( - authStub.admin.user.id, - new Set([assetStub.image.id]), - ); + expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set([asset.id])); }); it('should throw an error for no access', async () => { - await expect(sut.get(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.get(authStub.admin, AssetFactory.create().id)).rejects.toBeInstanceOf(BadRequestException); expect(mocks.asset.getById).not.toHaveBeenCalled(); }); it('should throw an error for an invalid shared link', async () => { - await expect(sut.get(authStub.adminSharedLink, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.get(authStub.adminSharedLink, AssetFactory.create().id)).rejects.toBeInstanceOf( + BadRequestException, + ); expect(mocks.access.asset.checkOwnerAccess).not.toHaveBeenCalled(); expect(mocks.asset.getById).not.toHaveBeenCalled(); }); it('should throw an error if the asset could not be found', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + const asset = AssetFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - await expect(sut.get(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.get(authStub.admin, asset.id)).rejects.toBeInstanceOf(BadRequestException); }); }); @@ -208,38 +210,41 @@ describe(AssetService.name, () => { }); it('should update the asset', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - mocks.asset.getById.mockResolvedValue(assetStub.image); - mocks.asset.update.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.update.mockResolvedValue(asset); - await sut.update(authStub.admin, 'asset-1', { isFavorite: true }); + await sut.update(authStub.admin, asset.id, { isFavorite: true }); - expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true }); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, isFavorite: true }); }); it('should update the exif description', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - mocks.asset.getById.mockResolvedValue(assetStub.image); - mocks.asset.update.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.update.mockResolvedValue(asset); - await sut.update(authStub.admin, 'asset-1', { description: 'Test description' }); + await sut.update(authStub.admin, asset.id, { description: 'Test description' }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( - { assetId: 'asset-1', description: 'Test description', lockedProperties: ['description'] }, + { assetId: asset.id, description: 'Test description', lockedProperties: ['description'] }, { lockedPropertiesBehavior: 'append' }, ); }); it('should update the exif rating', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - mocks.asset.getById.mockResolvedValueOnce(assetStub.image); - mocks.asset.update.mockResolvedValueOnce(assetStub.image); + const asset = AssetFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getById.mockResolvedValueOnce(asset); + mocks.asset.update.mockResolvedValueOnce(asset); - await sut.update(authStub.admin, 'asset-1', { rating: 3 }); + await sut.update(authStub.admin, asset.id, { rating: 3 }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( { - assetId: 'asset-1', + assetId: asset.id, rating: 3, lockedProperties: ['rating'], }, @@ -346,10 +351,11 @@ describe(AssetService.name, () => { it('should unlink a live video', async () => { const auth = AuthFactory.create(); + const asset = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoStillAsset); mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); - mocks.asset.update.mockResolvedValueOnce(assetStub.image); + mocks.asset.update.mockResolvedValueOnce(asset); await sut.update(auth, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null }); @@ -555,7 +561,11 @@ describe(AssetService.name, () => { describe('handleAssetDeletion', () => { it('should clean up files', async () => { - const asset = assetStub.image; + const asset = AssetFactory.from() + .file({ type: AssetFileType.Thumbnail }) + .file({ type: AssetFileType.Preview }) + .file({ type: AssetFileType.FullSize }) + .build(); mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset); await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true }); @@ -565,12 +575,7 @@ describe(AssetService.name, () => { { name: JobName.FileDelete, data: { - files: [ - '/uploads/user-id/webp/path.ext', - '/uploads/user-id/thumbs/path.jpg', - '/uploads/user-id/fullsize/path.webp', - asset.originalPath, - ], + files: [...asset.files.map(({ path }) => path), asset.originalPath], }, }, ], @@ -656,14 +661,15 @@ describe(AssetService.name, () => { }); it('should update usage', async () => { - mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetStub.image); - await sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true }); - expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.image.ownerId, -5000); + const asset = AssetFactory.from().exif({ fileSizeInByte: 5000 }).build(); + mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset); + await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true }); + expect(mocks.user.updateUsage).toHaveBeenCalledWith(asset.ownerId, -5000); }); it('should fail if asset could not be found', async () => { mocks.assetJob.getForAssetDeletion.mockResolvedValue(void 0); - await expect(sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true })).resolves.toBe( + await expect(sut.handleAssetDeletion({ id: AssetFactory.create().id, deleteOnDisk: true })).resolves.toBe( JobStatus.Failed, ); }); @@ -681,28 +687,30 @@ describe(AssetService.name, () => { it('should return OCR data for an asset', async () => { const ocr1 = factory.assetOcr({ text: 'Hello World' }); const ocr2 = factory.assetOcr({ text: 'Test Image' }); + const asset = AssetFactory.from().exif().build(); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.ocr.getByAssetId.mockResolvedValue([ocr1, ocr2]); - mocks.asset.getById.mockResolvedValue(assetStub.image); + mocks.asset.getById.mockResolvedValue(asset); - await expect(sut.getOcr(authStub.admin, 'asset-1')).resolves.toEqual([ocr1, ocr2]); + await expect(sut.getOcr(authStub.admin, asset.id)).resolves.toEqual([ocr1, ocr2]); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, - new Set(['asset-1']), + new Set([asset.id]), undefined, ); - expect(mocks.ocr.getByAssetId).toHaveBeenCalledWith('asset-1'); + expect(mocks.ocr.getByAssetId).toHaveBeenCalledWith(asset.id); }); it('should return empty array when no OCR data exists', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + const asset = AssetFactory.from().exif().build(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.ocr.getByAssetId.mockResolvedValue([]); - mocks.asset.getById.mockResolvedValue(assetStub.image); - await expect(sut.getOcr(authStub.admin, 'asset-1')).resolves.toEqual([]); + mocks.asset.getById.mockResolvedValue(asset); + await expect(sut.getOcr(authStub.admin, asset.id)).resolves.toEqual([]); - expect(mocks.ocr.getByAssetId).toHaveBeenCalledWith('asset-1'); + expect(mocks.ocr.getByAssetId).toHaveBeenCalledWith(asset.id); }); }); @@ -746,7 +754,7 @@ describe(AssetService.name, () => { describe('getUserAssetsByDeviceId', () => { it('get assets by device id', async () => { - const assets = [assetStub.image, assetStub.image1]; + const assets = [AssetFactory.create(), AssetFactory.create()]; mocks.asset.getAllByDeviceId.mockResolvedValue(assets.map((asset) => asset.deviceAssetId)); diff --git a/server/src/services/download.service.spec.ts b/server/src/services/download.service.spec.ts index 7721b12ffc..ae010623d8 100644 --- a/server/src/services/download.service.spec.ts +++ b/server/src/services/download.service.spec.ts @@ -3,7 +3,6 @@ import { Readable } from 'node:stream'; import { DownloadResponseDto } from 'src/dtos/download.dto'; import { DownloadService } from 'src/services/download.service'; import { AssetFactory } from 'test/factories/asset.factory'; -import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; import { vitest } from 'vitest'; @@ -37,21 +36,18 @@ describe(DownloadService.name, () => { finalize: vitest.fn(), stream: new Readable(), }; + const asset = AssetFactory.create(); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.noResizePath, id: 'asset-1' }]); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id, 'unknown-asset'])); + mocks.asset.getByIds.mockResolvedValue([asset]); mocks.storage.createZipStream.mockReturnValue(archiveMock); - await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ + await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset.id, 'unknown-asset'] })).resolves.toEqual({ stream: archiveMock.stream, }); expect(archiveMock.addFile).toHaveBeenCalledTimes(1); - expect(archiveMock.addFile).toHaveBeenNthCalledWith( - 1, - expect.stringContaining('/data/library/IMG_123.jpg'), - 'IMG_123.jpg', - ); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, asset.originalPath, asset.originalFileName); }); it('should log a warning if the original path could not be resolved', async () => { @@ -108,15 +104,14 @@ describe(DownloadService.name, () => { finalize: vitest.fn(), stream: new Readable(), }; + const asset1 = AssetFactory.create({ originalFileName: 'IMG_123.jpg' }); + const asset2 = AssetFactory.create({ originalFileName: 'IMG_123.jpg' }); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - mocks.asset.getByIds.mockResolvedValue([ - { ...assetStub.noResizePath, id: 'asset-1' }, - { ...assetStub.noResizePath, id: 'asset-2' }, - ]); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id])); + mocks.asset.getByIds.mockResolvedValue([asset1, asset2]); mocks.storage.createZipStream.mockReturnValue(archiveMock); - await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ + await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({ stream: archiveMock.stream, }); @@ -131,15 +126,14 @@ describe(DownloadService.name, () => { finalize: vitest.fn(), stream: new Readable(), }; + const asset1 = AssetFactory.create({ originalFileName: 'IMG_123.jpg' }); + const asset2 = AssetFactory.create({ originalFileName: 'IMG_123.jpg' }); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - mocks.asset.getByIds.mockResolvedValue([ - { ...assetStub.noResizePath, id: 'asset-2' }, - { ...assetStub.noResizePath, id: 'asset-1' }, - ]); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id])); + mocks.asset.getByIds.mockResolvedValue([asset2, asset1]); mocks.storage.createZipStream.mockReturnValue(archiveMock); - await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ + await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({ stream: archiveMock.stream, }); @@ -155,18 +149,17 @@ describe(DownloadService.name, () => { stream: new Readable(), }; - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - mocks.asset.getByIds.mockResolvedValue([ - { ...assetStub.noResizePath, id: 'asset-1', originalPath: '/path/to/symlink.jpg' }, - ]); + const asset = AssetFactory.create({ originalPath: '/path/to/symlink.jpg' }); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.asset.getByIds.mockResolvedValue([asset]); mocks.storage.realpath.mockResolvedValue('/path/to/realpath.jpg'); mocks.storage.createZipStream.mockReturnValue(archiveMock); - await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1'] })).resolves.toEqual({ + await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset.id] })).resolves.toEqual({ stream: archiveMock.stream, }); - expect(archiveMock.addFile).toHaveBeenCalledWith('/path/to/realpath.jpg', 'IMG_123.jpg'); + expect(archiveMock.addFile).toHaveBeenCalledWith('/path/to/realpath.jpg', asset.originalFileName); }); }); diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index e5ac9f82ba..57e40cc3f6 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -1,6 +1,7 @@ import { AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum'; import { DuplicateService } from 'src/services/duplicate.service'; import { SearchService } from 'src/services/search.service'; +import { AssetFactory } from 'test/factories/asset.factory'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; @@ -38,19 +39,17 @@ describe(SearchService.name, () => { describe('getDuplicates', () => { it('should get duplicates', async () => { + const asset = AssetFactory.create(); mocks.duplicateRepository.getAll.mockResolvedValue([ { duplicateId: 'duplicate-id', - assets: [assetStub.image, assetStub.image], + assets: [asset, asset], }, ]); await expect(sut.getDuplicates(authStub.admin)).resolves.toEqual([ { duplicateId: 'duplicate-id', - assets: [ - expect.objectContaining({ id: assetStub.image.id }), - expect.objectContaining({ id: assetStub.image.id }), - ], + assets: [expect.objectContaining({ id: asset.id }), expect.objectContaining({ id: asset.id })], }, ]); }); @@ -101,7 +100,8 @@ describe(SearchService.name, () => { }); it('should queue missing assets', async () => { - mocks.assetJob.streamForSearchDuplicates.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForSearchDuplicates.mockReturnValue(makeStream([asset])); await sut.handleQueueSearchDuplicates({}); @@ -109,13 +109,14 @@ describe(SearchService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetDetectDuplicates, - data: { id: assetStub.image.id }, + data: { id: asset.id }, }, ]); }); it('should queue all assets', async () => { - mocks.assetJob.streamForSearchDuplicates.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForSearchDuplicates.mockReturnValue(makeStream([asset])); await sut.handleQueueSearchDuplicates({ force: true }); @@ -123,7 +124,7 @@ describe(SearchService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetDetectDuplicates, - data: { id: assetStub.image.id }, + data: { id: asset.id }, }, ]); }); @@ -178,10 +179,11 @@ describe(SearchService.name, () => { it('should fail if asset is not found', async () => { mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue(void 0); - const result = await sut.handleSearchDuplicates({ id: assetStub.image.id }); + const asset = AssetFactory.create(); + const result = await sut.handleSearchDuplicates({ id: asset.id }); expect(result).toBe(JobStatus.Failed); - expect(mocks.logger.error).toHaveBeenCalledWith(`Asset ${assetStub.image.id} not found`); + expect(mocks.logger.error).toHaveBeenCalledWith(`Asset ${asset.id} not found`); }); it('should skip if asset is part of stack', async () => { @@ -210,19 +212,19 @@ describe(SearchService.name, () => { it('should fail if asset is missing embedding', async () => { mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, embedding: null }); - const result = await sut.handleSearchDuplicates({ id: assetStub.image.id }); + const asset = AssetFactory.create(); + const result = await sut.handleSearchDuplicates({ id: asset.id }); expect(result).toBe(JobStatus.Failed); - expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${assetStub.image.id} is missing embedding`); + expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${asset.id} is missing embedding`); }); it('should search for duplicates and update asset with duplicateId', async () => { mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue(hasEmbedding); - mocks.duplicateRepository.search.mockResolvedValue([ - { assetId: assetStub.image.id, distance: 0.01, duplicateId: null }, - ]); + const asset = AssetFactory.create(); + mocks.duplicateRepository.search.mockResolvedValue([{ assetId: asset.id, distance: 0.01, duplicateId: null }]); mocks.duplicateRepository.merge.mockResolvedValue(); - const expectedAssetIds = [assetStub.image.id, hasEmbedding.id]; + const expectedAssetIds = [asset.id, hasEmbedding.id]; const result = await sut.handleSearchDuplicates({ id: hasEmbedding.id }); diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index c23b4f05df..00460e3cc0 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -1,6 +1,7 @@ import { ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum'; import { JobService } from 'src/services/job.service'; import { JobItem } from 'src/types'; +import { AssetFactory } from 'test/factories/asset.factory'; import { assetStub } from 'test/fixtures/asset.stub'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -55,7 +56,7 @@ describe(JobService.name, () => { { item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1' } }, jobs: [], - stub: [assetStub.image], + stub: [AssetFactory.create({ id: 'asset-id' })], }, { item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1' } }, diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index dbff1ca467..67eea0fe3f 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -6,6 +6,7 @@ import { mapLibrary } from 'src/dtos/library.dto'; import { AssetType, CronJob, ImmichWorker, JobName, JobStatus } from 'src/enum'; import { LibraryService } from 'src/services/library.service'; import { ILibraryBulkIdsJob, ILibraryFileJob } from 'src/types'; +import { AssetFactory } from 'test/factories/asset.factory'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; @@ -548,13 +549,14 @@ describe(LibraryService.name, () => { it('should import a new asset', async () => { const library = factory.library(); + const asset = AssetFactory.create(); const mockLibraryJob: ILibraryFileJob = { libraryId: library.id, paths: ['/data/user1/photo.jpg'], }; - mocks.asset.createAll.mockResolvedValue([assetStub.image]); + mocks.asset.createAll.mockResolvedValue([asset]); mocks.library.get.mockResolvedValue(library); await expect(sut.handleSyncFiles(mockLibraryJob)).resolves.toBe(JobStatus.Success); @@ -575,7 +577,7 @@ describe(LibraryService.name, () => { { name: JobName.SidecarCheck, data: { - id: assetStub.image.id, + id: asset.id, source: 'upload', }, }, @@ -602,7 +604,7 @@ describe(LibraryService.name, () => { it('should delete a library', async () => { const library = factory.library(); - mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(AssetFactory.create()); mocks.library.get.mockResolvedValue(library); await sut.delete(library.id); @@ -614,7 +616,7 @@ describe(LibraryService.name, () => { it('should allow an external library to be deleted', async () => { const library = factory.library(); - mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(AssetFactory.create()); mocks.library.get.mockResolvedValue(library); await sut.delete(library.id); @@ -630,7 +632,7 @@ describe(LibraryService.name, () => { it('should unwatch an external library when deleted', async () => { const library = factory.library({ importPaths: ['/foo', '/bar'] }); - mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(AssetFactory.create()); mocks.library.get.mockResolvedValue(library); mocks.library.getAll.mockResolvedValue([library]); @@ -962,7 +964,7 @@ describe(LibraryService.name, () => { mocks.library.get.mockResolvedValue(library); mocks.library.getAll.mockResolvedValue([library]); - mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(AssetFactory.create()); mocks.storage.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/foo/photo.jpg' }] })); await sut.watchAll(); @@ -981,7 +983,7 @@ describe(LibraryService.name, () => { mocks.library.get.mockResolvedValue(library); mocks.library.getAll.mockResolvedValue([library]); - mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(AssetFactory.create()); mocks.storage.watch.mockImplementation( makeMockWatcher({ items: [{ event: 'change', value: '/foo/photo.jpg' }] }), ); @@ -999,12 +1001,13 @@ describe(LibraryService.name, () => { it('should handle a file unlink event', async () => { const library = factory.library({ importPaths: ['/foo', '/bar'] }); + const asset = AssetFactory.create(); mocks.library.get.mockResolvedValue(library); mocks.library.getAll.mockResolvedValue([library]); - mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(asset); mocks.storage.watch.mockImplementation( - makeMockWatcher({ items: [{ event: 'unlink', value: assetStub.image.originalPath }] }), + makeMockWatcher({ items: [{ event: 'unlink', value: asset.originalPath }] }), ); await sut.watchAll(); @@ -1013,7 +1016,7 @@ describe(LibraryService.name, () => { name: JobName.LibraryRemoveAsset, data: { libraryId: library.id, - paths: [assetStub.image.originalPath], + paths: [asset.originalPath], }, }); }); @@ -1115,7 +1118,7 @@ describe(LibraryService.name, () => { const library = factory.library(); mocks.library.get.mockResolvedValue(library); - mocks.library.streamAssetIds.mockReturnValue(makeStream([assetStub.image1])); + mocks.library.streamAssetIds.mockReturnValue(makeStream([AssetFactory.create()])); await expect(sut.handleDeleteLibrary({ id: library.id })).resolves.toBe(JobStatus.Success); }); diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 823383c29d..a15211c6c3 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -1,6 +1,7 @@ import { OutputInfo } from 'sharp'; import { SystemConfig } from 'src/config'; import { Exif } from 'src/database'; +import { AssetEditAction } from 'src/dtos/editing.dto'; import { AssetFileType, AssetPathType, @@ -19,7 +20,7 @@ import { import { MediaService } from 'src/services/media.service'; import { JobCounts, RawImageInfo } from 'src/types'; import { AssetFactory } from 'test/factories/asset.factory'; -import { assetStub, previewFile } from 'test/fixtures/asset.stub'; +import { assetStub } from 'test/fixtures/asset.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { probeStub } from 'test/fixtures/media.stub'; import { personStub, personThumbnailStub } from 'test/fixtures/person.stub'; @@ -45,7 +46,8 @@ describe(MediaService.name, () => { describe('handleQueueGenerateThumbnails', () => { it('should queue all assets', async () => { - mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); mocks.person.getAll.mockReturnValue(makeStream([personStub.newThumbnail])); @@ -55,7 +57,7 @@ describe(MediaService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetGenerateThumbnails, - data: { id: assetStub.image.id }, + data: { id: asset.id }, }, ]); @@ -99,7 +101,7 @@ describe(MediaService.name, () => { }); it('should queue all people with missing thumbnail path', async () => { - mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.image])); + mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([AssetFactory.create()])); mocks.person.getAll.mockReturnValue(makeStream([personStub.noThumbnail, personStub.noThumbnail])); mocks.person.getRandomFace.mockResolvedValueOnce(faceStub.face1); @@ -120,7 +122,8 @@ describe(MediaService.name, () => { }); it('should queue all assets with missing resize path', async () => { - mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.noResizePath])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); @@ -128,7 +131,7 @@ describe(MediaService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetGenerateThumbnails, - data: { id: assetStub.image.id }, + data: { id: asset.id }, }, ]); @@ -264,16 +267,15 @@ describe(MediaService.name, () => { describe('handleQueueMigration', () => { it('should remove empty directories and queue jobs', async () => { - mocks.assetJob.streamForMigrationJob.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForMigrationJob.mockReturnValue(makeStream([asset])); mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts); mocks.person.getAll.mockReturnValue(makeStream([personStub.withName])); await expect(sut.handleQueueMigration()).resolves.toBe(JobStatus.Success); expect(mocks.storage.removeEmptyDirs).toHaveBeenCalledTimes(2); - expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.AssetFileMigration, data: { id: assetStub.image.id } }, - ]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.AssetFileMigration, data: { id: asset.id } }]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.PersonFileMigration, data: { id: personStub.withName.id } }, ]); @@ -283,39 +285,42 @@ describe(MediaService.name, () => { describe('handleAssetMigration', () => { it('should fail if asset does not exist', async () => { mocks.assetJob.getForMigrationJob.mockResolvedValue(void 0); - await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.Failed); + await expect(sut.handleAssetMigration({ id: 'non-existent' })).resolves.toBe(JobStatus.Failed); expect(mocks.move.getByEntity).not.toHaveBeenCalled(); }); it('should move asset files', async () => { - mocks.assetJob.getForMigrationJob.mockResolvedValue(assetStub.image); + const asset = AssetFactory.from() + .files([AssetFileType.FullSize, AssetFileType.Preview, AssetFileType.Thumbnail]) + .build(); + mocks.assetJob.getForMigrationJob.mockResolvedValue(asset); mocks.move.create.mockResolvedValue({ - entityId: assetStub.image.id, + entityId: asset.id, id: 'move-id', newPath: '/new/path', oldPath: '/old/path', pathType: AssetPathType.Original, }); - await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.Success); + await expect(sut.handleAssetMigration({ id: asset.id })).resolves.toBe(JobStatus.Success); expect(mocks.move.create).toHaveBeenCalledWith({ - entityId: assetStub.image.id, + entityId: asset.id, pathType: AssetFileType.FullSize, - oldPath: '/uploads/user-id/fullsize/path.webp', - newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id_fullsize.jpeg'), + oldPath: asset.files[0].path, + newPath: `/data/thumbs/${asset.ownerId}/${asset.id.slice(0, 2)}/${asset.id.slice(2, 4)}/${asset.id}_fullsize.jpeg`, }); expect(mocks.move.create).toHaveBeenCalledWith({ - entityId: assetStub.image.id, + entityId: asset.id, pathType: AssetFileType.Preview, - oldPath: '/uploads/user-id/thumbs/path.jpg', - newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id_preview.jpeg'), + oldPath: asset.files[1].path, + newPath: `/data/thumbs/${asset.ownerId}/${asset.id.slice(0, 2)}/${asset.id.slice(2, 4)}/${asset.id}_preview.jpeg`, }); expect(mocks.move.create).toHaveBeenCalledWith({ - entityId: assetStub.image.id, + entityId: asset.id, pathType: AssetFileType.Thumbnail, - oldPath: '/uploads/user-id/webp/path.ext', - newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id_thumbnail.webp'), + oldPath: asset.files[2].path, + newPath: `/data/thumbs/${asset.ownerId}/${asset.id.slice(0, 2)}/${asset.id.slice(2, 4)}/${asset.id}_thumbnail.webp`, }); expect(mocks.move.create).toHaveBeenCalledTimes(3); }); @@ -339,16 +344,17 @@ describe(MediaService.name, () => { it('should skip thumbnail generation if asset not found', async () => { mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(void 0); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: 'non-existent' }); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalledWith(); }); it('should skip thumbnail generation if asset type is unknown', async () => { - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ ...assetStub.image, type: 'foo' as AssetType }); + const asset = AssetFactory.create({ type: 'foo' as AssetType }); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await expect(sut.handleGenerateThumbnails({ id: assetStub.image.id })).resolves.toBe(JobStatus.Skipped); + await expect(sut.handleGenerateThumbnails({ id: asset.id })).resolves.toBe(JobStatus.Skipped); expect(mocks.media.probe).not.toHaveBeenCalled(); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalledWith(); @@ -372,33 +378,35 @@ describe(MediaService.name, () => { }); it('should delete previous preview if different path', async () => { + const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).exif().build(); mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.Webp } } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FileDelete, data: { - files: expect.arrayContaining([previewFile.path]), + files: expect.arrayContaining([asset.files[0].path]), }, }); }); it('should generate P3 thumbnails for a wide gamut image', async () => { - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ - ...assetStub.image, - exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as Exif, - }); + const asset = AssetFactory.from() + .exif({ profileDescription: 'Adobe RGB', bitsPerSample: 14 }) + .files([AssetFileType.Preview, AssetFileType.Thumbnail]) + .build(); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, @@ -444,21 +452,21 @@ describe(MediaService.name, () => { expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ { - assetId: 'asset-id', + assetId: asset.id, type: AssetFileType.Preview, path: expect.any(String), isEdited: false, isProgressive: false, }, { - assetId: 'asset-id', + assetId: asset.id, type: AssetFileType.Thumbnail, path: expect.any(String), isEdited: false, isProgressive: false, }, ]); - expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, thumbhash: thumbhashBuffer }); }); it('should generate a thumbnail for a video', async () => { @@ -618,18 +626,19 @@ describe(MediaService.name, () => { }); it.each(Object.values(ImageFormat))('should generate an image preview in %s format', async (format) => { + const asset = AssetFactory.from().exif().build(); mocks.systemMetadata.get.mockResolvedValue({ image: { preview: { format } } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); - const previewPath = `/data/thumbs/user-id/as/se/asset-id_preview.${format}`; - const thumbnailPath = `/data/thumbs/user-id/as/se/asset-id_thumbnail.webp`; + const previewPath = `/data/thumbs/${asset.ownerId}/${asset.id.slice(0, 2)}/${asset.id.slice(2, 4)}/${asset.id}_preview.${format}`; + const thumbnailPath = `/data/thumbs/${asset.ownerId}/${asset.id.slice(0, 2)}/${asset.id.slice(2, 4)}/${asset.id}_thumbnail.webp`; - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { colorspace: Colorspace.Srgb, processInvalidImages: false, size: 1440, @@ -667,18 +676,19 @@ describe(MediaService.name, () => { }); it.each(Object.values(ImageFormat))('should generate an image thumbnail in %s format', async (format) => { + const asset = AssetFactory.from().exif().build(); mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format } } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); - const previewPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id_preview.jpeg`); - const thumbnailPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id_thumbnail.${format}`); + const previewPath = `/data/thumbs/${asset.ownerId}/${asset.id.slice(0, 2)}/${asset.id.slice(2, 4)}/${asset.id}_preview.jpeg`; + const thumbnailPath = `/data/thumbs/${asset.ownerId}/${asset.id.slice(0, 2)}/${asset.id.slice(2, 4)}/${asset.id}_thumbnail.${format}`; - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { colorspace: Colorspace.Srgb, processInvalidImages: false, size: 1440, @@ -716,12 +726,13 @@ describe(MediaService.name, () => { }); it('should generate progressive JPEG for preview when enabled', async () => { + const asset = AssetFactory.from().exif().build(); mocks.systemMetadata.get.mockResolvedValue({ image: { preview: { progressive: true }, thumbnail: { progressive: false } }, }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, @@ -752,12 +763,13 @@ describe(MediaService.name, () => { }); it('should generate progressive JPEG for thumbnail when enabled', async () => { + const asset = AssetFactory.from().exif().build(); mocks.systemMetadata.get.mockResolvedValue({ image: { preview: { progressive: false }, thumbnail: { format: ImageFormat.Jpeg, progressive: true } }, }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, @@ -809,26 +821,30 @@ describe(MediaService.name, () => { }); it('should delete previous thumbnail if different path', async () => { + const asset = AssetFactory.from().exif().file({ type: AssetFileType.Preview }).build(); mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.Webp } } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FileDelete, data: { - files: expect.arrayContaining([previewFile.path]), + files: expect.arrayContaining([asset.files[0].path]), }, }); }); it('should extract embedded image if enabled and available', async () => { + const asset = AssetFactory.from({ originalFileName: 'file.dng' }) + .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) + .build(); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedBuffer, { @@ -839,14 +855,17 @@ describe(MediaService.name, () => { }); it('should resize original image if embedded image is too small', async () => { + const asset = AssetFactory.from({ originalFileName: 'file.dng' }) + .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) + .build(); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, @@ -854,13 +873,16 @@ describe(MediaService.name, () => { }); it('should resize original image if embedded image not found', async () => { + const asset = AssetFactory.from({ originalFileName: 'file.dng' }) + .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) + .build(); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, @@ -868,14 +890,17 @@ describe(MediaService.name, () => { }); it('should resize original image if embedded image extraction is not enabled', async () => { + const asset = AssetFactory.from({ originalFileName: 'file.dng' }) + .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) + .build(); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: false } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.extract).not.toHaveBeenCalled(); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, size: 1440, @@ -884,14 +909,17 @@ describe(MediaService.name, () => { it('should process invalid images if enabled', async () => { vi.stubEnv('IMMICH_PROCESS_INVALID_IMAGES', 'true'); + const asset = AssetFactory.from({ originalFileName: 'file.dng' }) + .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) + .build(); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith( - assetStub.imageDng.originalPath, + asset.originalPath, expect.objectContaining({ processInvalidImages: true }), ); @@ -917,14 +945,18 @@ describe(MediaService.name, () => { }); it('should extract full-size JPEG preview from RAW', async () => { + const asset = AssetFactory.from({ originalFileName: 'file.dng' }) + .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) + .build(); + mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true, format: ImageFormat.Webp }, extractEmbedded: true }, }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedBuffer, { @@ -951,14 +983,18 @@ describe(MediaService.name, () => { }); it('should convert full-size WEBP preview from JXL preview of RAW', async () => { + const asset = AssetFactory.from({ originalFileName: 'file.dng' }) + .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) + .build(); + mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true, format: ImageFormat.Webp }, extractEmbedded: true }, }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jxl }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedBuffer, { @@ -997,15 +1033,19 @@ describe(MediaService.name, () => { }); it('should generate full-size preview directly from RAW images when extractEmbedded is false', async () => { + const asset = AssetFactory.from({ originalFileName: 'file.dng' }) + .exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined }) + .build(); + mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true }, extractEmbedded: false } }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, }); @@ -1079,15 +1119,16 @@ describe(MediaService.name, () => { }); it('should skip generating full-size preview for web-friendly images', async () => { + const asset = AssetFactory.from().exif().build(); mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { colorspace: Colorspace.Srgb, processInvalidImages: false, size: 1440, @@ -1116,7 +1157,7 @@ describe(MediaService.name, () => { mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { @@ -1161,7 +1202,7 @@ describe(MediaService.name, () => { .build(); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { @@ -1238,15 +1279,23 @@ describe(MediaService.name, () => { }); it('should upsert 3 edited files for edit jobs', async () => { - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ - ...assetStub.withCropEdit, - }); + const asset = AssetFactory.from() + .exif() + .edit({ action: AssetEditAction.Crop }) + .files([ + { type: AssetFileType.FullSize, isEdited: true }, + { type: AssetFileType.Preview, isEdited: true }, + { type: AssetFileType.Thumbnail, isEdited: true }, + ]) + .build(); + + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); mocks.person.getFaces.mockResolvedValue([]); mocks.ocr.getByAssetId.mockResolvedValue([]); - await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id }); + await sut.handleAssetEditThumbnailGeneration({ id: asset.id }); expect(mocks.asset.upsertFiles).toHaveBeenCalledWith( expect.arrayContaining([ @@ -1258,21 +1307,23 @@ describe(MediaService.name, () => { }); it('should apply edits when generating thumbnails', async () => { - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ - ...assetStub.withCropEdit, - }); + const asset = AssetFactory.from() + .exif() + .edit({ action: AssetEditAction.Crop, parameters: { height: 1152, width: 1512, x: 216, y: 1512 } }) + .build(); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); mocks.person.getFaces.mockResolvedValue([]); mocks.ocr.getByAssetId.mockResolvedValue([]); - await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id }); + await sut.handleAssetEditThumbnailGeneration({ id: asset.id }); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, expect.objectContaining({ edits: [ - { + expect.objectContaining({ action: 'crop', parameters: { height: 1152, width: 1512, x: 216, y: 1512 }, - }, + }), ], }), expect.any(String), @@ -1305,13 +1356,12 @@ describe(MediaService.name, () => { }); it('should generate all 3 edited files if an asset has edits', async () => { - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ - ...assetStub.withCropEdit, - }); + const asset = AssetFactory.from().exif().edit().build(); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); mocks.person.getFaces.mockResolvedValue([]); mocks.ocr.getByAssetId.mockResolvedValue([]); - await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id }); + await sut.handleAssetEditThumbnailGeneration({ id: asset.id }); expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( @@ -1336,21 +1386,20 @@ describe(MediaService.name, () => { mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); mocks.media.generateThumbhash.mockResolvedValue(factory.buffer()); - await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id, source: 'upload' }); + await sut.handleAssetEditThumbnailGeneration({ id: asset.id, source: 'upload' }); expect(mocks.media.generateThumbhash).toHaveBeenCalled(); }); it('should apply thumbhash if job source is edit and edits exist', async () => { - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ - ...assetStub.withCropEdit, - }); + const asset = AssetFactory.from().exif().edit().build(); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); const thumbhashBuffer = factory.buffer(); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); mocks.person.getFaces.mockResolvedValue([]); mocks.ocr.getByAssetId.mockResolvedValue([]); - await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id }); + await sut.handleAssetEditThumbnailGeneration({ id: asset.id }); expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ thumbhash: thumbhashBuffer })); }); diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index d94de020e0..b20bc8b46f 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -125,27 +125,29 @@ describe(MetadataService.name, () => { describe('handleQueueMetadataExtraction', () => { it('should queue metadata extraction for all assets without exif values', async () => { - mocks.assetJob.streamForMetadataExtraction.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForMetadataExtraction.mockReturnValue(makeStream([asset])); await expect(sut.handleQueueMetadataExtraction({ force: false })).resolves.toBe(JobStatus.Success); expect(mocks.assetJob.streamForMetadataExtraction).toHaveBeenCalledWith(false); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetExtractMetadata, - data: { id: assetStub.image.id }, + data: { id: asset.id }, }, ]); }); it('should queue metadata extraction for all assets', async () => { - mocks.assetJob.streamForMetadataExtraction.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForMetadataExtraction.mockReturnValue(makeStream([asset])); await expect(sut.handleQueueMetadataExtraction({ force: true })).resolves.toBe(JobStatus.Success); expect(mocks.assetJob.streamForMetadataExtraction).toHaveBeenCalledWith(true); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetExtractMetadata, - data: { id: assetStub.image.id }, + data: { id: asset.id }, }, ]); }); @@ -166,9 +168,9 @@ describe(MetadataService.name, () => { it('should handle an asset that could not be found', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(void 0); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: 'non-existent' }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith('non-existent'); expect(mocks.asset.upsertExif).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalled(); }); @@ -287,8 +289,8 @@ describe(MetadataService.name, () => { } as Stats); mockReadTags({ ISO: [160] }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 }), { lockedPropertiesBehavior: 'skip', }); @@ -406,7 +408,7 @@ describe(MetadataService.name, () => { mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { userId: asset.ownerId, @@ -546,57 +548,59 @@ describe(MetadataService.name, () => { mockReadTags({ HierarchicalSubject: ['Parent', 2024] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: 'Parent', parent: undefined }); expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: '2024', parent: undefined }); }); it('should extract ignore / characters in a HierarchicalSubject tag', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Mom|Dad'] }) }); mockReadTags({ HierarchicalSubject: ['Mom/Dad'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ - userId: 'user-id', + userId: asset.ownerId, value: 'Mom|Dad', parent: undefined, }); }); it('should ignore HierarchicalSubject when TagsList is present', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); - mocks.asset.getById.mockResolvedValue({ - ...factory.asset(), - exifInfo: factory.exif({ tags: ['Parent/Child', 'Parent2/Child2'] }), - }); + const baseAsset = AssetFactory.from(); + const asset = baseAsset.build(); + const updatedAsset = baseAsset.exif({ tags: ['Parent/Child', 'Parent2/Child2'] }).build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(updatedAsset); mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { - userId: 'user-id', + userId: asset.ownerId, value: 'Parent', parentId: undefined, }); expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { - userId: 'user-id', + userId: asset.ownerId, value: 'Parent/Child', parentId: 'tag-parent', }); }); it('should remove existing tags', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({}); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); - expect(mocks.tag.replaceAssetTags).toHaveBeenCalledWith('asset-id', []); + expect(mocks.tag.replaceAssetTags).toHaveBeenCalledWith(asset.id, []); }); it('should not apply motion photos if asset is video', async () => { @@ -617,13 +621,14 @@ describe(MetadataService.name, () => { }); it('should handle an invalid Directory Item', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ MotionPhoto: 1, ContainerDirectory: [{ Foo: 100 }], }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); }); it('should extract the correct video orientation', async () => { @@ -915,6 +920,7 @@ describe(MetadataService.name, () => { it('should save all metadata', async () => { const dateForTest = new Date('1970-01-01T00:00:00.000-11:30'); + const asset = AssetFactory.create(); const tags: ImmichTags = { BitsPerSample: 1, @@ -941,14 +947,14 @@ describe(MetadataService.name, () => { Rating: 3, }; - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags(tags); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( { - assetId: assetStub.image.id, + assetId: asset.id, bitsPerSample: expect.any(Number), autoStackId: null, colorspace: tags.ColorSpace, @@ -983,7 +989,7 @@ describe(MetadataService.name, () => { ); expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ - id: assetStub.image.id, + id: asset.id, duration: null, fileCreatedAt: dateForTest, localDateTime: DateTime.fromISO('1970-01-01T00:00:00.000Z').toJSDate(), @@ -996,6 +1002,7 @@ describe(MetadataService.name, () => { // https://github.com/photostructure/exiftool-vendored.js/issues/203 // this only tests our assumptions of exiftool-vendored, demonstrating the issue + const asset = AssetFactory.create(); const someDate = '2024-09-01T00:00:00.000'; expect(ExifDateTime.fromISO(someDate + 'Z')?.zone).toBe('UTC'); expect(ExifDateTime.fromISO(someDate + '+00:00')?.zone).toBe('UTC'); // this is the issue, should be UTC+0 @@ -1005,11 +1012,11 @@ describe(MetadataService.name, () => { DateTimeOriginal: ExifDateTime.fromISO(someDate + '+00:00'), tz: undefined, }; - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags(tags); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ timeZone: 'UTC+0', @@ -1034,14 +1041,15 @@ describe(MetadataService.name, () => { expect(mocks.asset.upsertExif).toHaveBeenCalled(); expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ - id: assetStub.image.id, + id: assetStub.video.id, duration: '00:00:06.210', }), ); }); it('should only extract duration for videos', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.media.probe.mockResolvedValue({ ...probeStub.videoStreamH264, format: { @@ -1049,13 +1057,13 @@ describe(MetadataService.name, () => { duration: 6.21, }, }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalled(); expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ - id: assetStub.image.id, + id: asset.id, duration: null, }), ); @@ -1077,7 +1085,7 @@ describe(MetadataService.name, () => { expect(mocks.asset.upsertExif).toHaveBeenCalled(); expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ - id: assetStub.image.id, + id: assetStub.video.id, duration: null, }), ); @@ -1106,45 +1114,34 @@ describe(MetadataService.name, () => { }); it('should use Duration from exif', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ - ...assetStub.image, - originalPath: '/original/path.webp', - }); + const asset = AssetFactory.create({ originalFileName: 'file.webp' }); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ Duration: 123 }, {}); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.metadata.readTags).toHaveBeenCalledTimes(1); expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: '00:02:03.000' })); }); it('should prefer Duration from exif over sidecar', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ - ...assetStub.image, - originalPath: '/original/path.webp', - files: [ - { - id: 'some-id', - type: AssetFileType.Sidecar, - path: '/path/to/something', - isEdited: false, - }, - ], - }); + const asset = AssetFactory.from({ originalFileName: 'file.webp' }).file({ type: AssetFileType.Sidecar }).build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ Duration: 123 }, { Duration: 456 }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.metadata.readTags).toHaveBeenCalledTimes(2); expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: '00:02:03.000' })); }); it('should ignore all Duration tags for definitely static images', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.imageDng); + const asset = AssetFactory.from({ originalFileName: 'file.dng' }).build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ Duration: 123 }, { Duration: 456 }); - await sut.handleMetadataExtraction({ id: assetStub.imageDng.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.metadata.readTags).toHaveBeenCalledTimes(1); expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: null })); @@ -1168,10 +1165,11 @@ describe(MetadataService.name, () => { }); it('should trim whitespace from description', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ Description: '\t \v \f \n \r' }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ description: '', @@ -1180,7 +1178,7 @@ describe(MetadataService.name, () => { ); mockReadTags({ ImageDescription: ' my\n description' }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ description: 'my\n description', @@ -1190,10 +1188,11 @@ describe(MetadataService.name, () => { }); it('should handle a numeric description', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ Description: 1000 }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ description: '1000', @@ -1203,40 +1202,44 @@ describe(MetadataService.name, () => { }); it('should skip importing metadata when the feature is disabled', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: false } } }); mockReadTags(makeFaceTags({ Name: 'Person 1' })); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.person.getDistinctNames).not.toHaveBeenCalled(); }); it('should skip importing metadata face for assets without tags.RegionInfo', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.person.getDistinctNames).not.toHaveBeenCalled(); }); it('should skip importing faces without name', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(makeFaceTags()); mocks.person.getDistinctNames.mockResolvedValue([]); mocks.person.createAll.mockResolvedValue([]); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.person.createAll).not.toHaveBeenCalled(); expect(mocks.person.refreshFaces).not.toHaveBeenCalled(); expect(mocks.person.updateAll).not.toHaveBeenCalled(); }); it('should skip importing faces with empty name', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(makeFaceTags({ Name: '' })); mocks.person.getDistinctNames.mockResolvedValue([]); mocks.person.createAll.mockResolvedValue([]); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.person.createAll).not.toHaveBeenCalled(); expect(mocks.person.refreshFaces).not.toHaveBeenCalled(); expect(mocks.person.updateAll).not.toHaveBeenCalled(); @@ -1414,10 +1417,11 @@ describe(MetadataService.name, () => { }); it('should handle invalid modify date', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ ModifyDate: '00:00:00.000' }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ modifyDate: expect.any(Date), @@ -1427,10 +1431,11 @@ describe(MetadataService.name, () => { }); it('should handle invalid rating value', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ Rating: 6 }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ rating: null, @@ -1440,10 +1445,11 @@ describe(MetadataService.name, () => { }); it('should handle valid rating value', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ Rating: 5 }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ rating: 5, @@ -1453,10 +1459,11 @@ describe(MetadataService.name, () => { }); it('should handle valid negative rating value', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ Rating: -1 }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ rating: -1, @@ -1466,11 +1473,12 @@ describe(MetadataService.name, () => { }); it('should handle livePhotoCID not set', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.findLivePhotoMatch).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalledWith( expect.objectContaining({ visibility: AssetVisibility.Hidden }), @@ -1579,10 +1587,11 @@ describe(MetadataService.name, () => { }, { exif: { AndroidMake: '1', AndroidModel: '2' }, expected: { make: '1', model: '2' } }, ])('should read camera make and model $exif -> $expected', async ({ exif, expected }) => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags(exif); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining(expected), { lockedPropertiesBehavior: 'skip', }); @@ -1603,10 +1612,11 @@ describe(MetadataService.name, () => { { exif: { LensID: ' Unknown 6-30mm' }, expected: null }, { exif: { LensID: '' }, expected: null }, ])('should read camera lens information $exif -> $expected', async ({ exif, expected }) => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags(exif); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ lensModel: expected, @@ -1616,10 +1626,11 @@ describe(MetadataService.name, () => { }); it('should properly set width/height for normal images', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ ImageWidth: 1000, ImageHeight: 2000 }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ width: 1000, @@ -1629,10 +1640,11 @@ describe(MetadataService.name, () => { }); it('should properly swap asset width/height for rotated images', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ ImageWidth: 1000, ImageHeight: 2000, Orientation: 6 }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ width: 2000, @@ -1642,14 +1654,11 @@ describe(MetadataService.name, () => { }); it('should not overwrite existing width/height if they already exist', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ - ...assetStub.image, - width: 1920, - height: 1080, - }); + const asset = AssetFactory.create({ width: 1920, height: 1080 }); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ ImageWidth: 1280, ImageHeight: 720 }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.update).not.toHaveBeenCalledWith( expect.objectContaining({ width: 1280, @@ -1685,7 +1694,7 @@ describe(MetadataService.name, () => { it('should do nothing if asset could not be found', async () => { mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(void 0); - await expect(sut.handleSidecarCheck({ id: assetStub.image.id })).resolves.toBeUndefined(); + await expect(sut.handleSidecarCheck({ id: 'non-existent' })).resolves.toBeUndefined(); expect(mocks.asset.update).not.toHaveBeenCalled(); }); diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index bece89d73e..ee4b4ec05f 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -6,6 +6,7 @@ import { NotificationService } from 'src/services/notification.service'; import { INotifyAlbumUpdateJob } from 'src/types'; import { AlbumFactory } from 'test/factories/album.factory'; import { AssetFileFactory } from 'test/factories/asset-file.factory'; +import { AssetFactory } from 'test/factories/asset.factory'; import { UserFactory } from 'test/factories/user.factory'; import { notificationStub } from 'test/fixtures/notification.stub'; import { userStub } from 'test/fixtures/user.stub'; @@ -392,8 +393,8 @@ describe(NotificationService.name, () => { }); it('should send invite email with album thumbnail and arbitrary extension', async () => { - const assetFile = AssetFileFactory.create({ path: 'some-thumb.ext', type: AssetFileType.Thumbnail }); - const album = AlbumFactory.create({ albumThumbnailAssetId: assetFile.assetId }); + const asset = AssetFactory.from().file({ type: AssetFileType.Thumbnail }).build(); + const album = AlbumFactory.from({ albumThumbnailAssetId: asset.id }).asset(asset).build(); mocks.album.getById.mockResolvedValue(album); mocks.user.get.mockResolvedValue({ ...userStub.user1, @@ -407,7 +408,7 @@ describe(NotificationService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ server: {} }); mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); - mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([assetFile]); + mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([asset.files[0]]); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success); expect(mocks.assetJob.getAlbumThumbnailFiles).toHaveBeenCalledWith( @@ -418,7 +419,7 @@ describe(NotificationService.name, () => { name: JobName.SendMail, data: expect.objectContaining({ subject: expect.stringContaining('You have been added to a shared album'), - imageAttachments: [{ filename: 'album-thumbnail.ext', path: expect.anything(), cid: expect.anything() }], + imageAttachments: [{ filename: 'album-thumbnail.jpg', path: expect.anything(), cid: expect.anything() }], }), }); }); diff --git a/server/src/services/ocr.service.spec.ts b/server/src/services/ocr.service.spec.ts index 404f423cac..d5b146e942 100644 --- a/server/src/services/ocr.service.spec.ts +++ b/server/src/services/ocr.service.spec.ts @@ -1,6 +1,6 @@ -import { AssetVisibility, ImmichWorker, JobName, JobStatus } from 'src/enum'; +import { AssetFileType, AssetVisibility, ImmichWorker, JobName, JobStatus } from 'src/enum'; import { OcrService } from 'src/services/ocr.service'; -import { assetStub } from 'test/fixtures/asset.stub'; +import { AssetFactory } from 'test/factories/asset.factory'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; @@ -14,7 +14,7 @@ describe(OcrService.name, () => { mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices); mocks.assetJob.getForOcr.mockResolvedValue({ visibility: AssetVisibility.Timeline, - previewFile: assetStub.image.files[1].path, + previewFile: '/uploads/user-id/thumbs/path.jpg', }); }); @@ -41,20 +41,22 @@ describe(OcrService.name, () => { }); it('should queue the assets without ocr', async () => { - mocks.assetJob.streamForOcrJob.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForOcrJob.mockReturnValue(makeStream([asset])); await sut.handleQueueOcr({ force: false }); - expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.Ocr, data: { id: assetStub.image.id } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.Ocr, data: { id: asset.id } }]); expect(mocks.assetJob.streamForOcrJob).toHaveBeenCalledWith(false); }); it('should queue all the assets', async () => { - mocks.assetJob.streamForOcrJob.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForOcrJob.mockReturnValue(makeStream([asset])); await sut.handleQueueOcr({ force: true }); - expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.Ocr, data: { id: assetStub.image.id } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.Ocr, data: { id: asset.id } }]); expect(mocks.assetJob.streamForOcrJob).toHaveBeenCalledWith(true); }); }); @@ -70,15 +72,17 @@ describe(OcrService.name, () => { }); it('should skip assets without a resize path', async () => { + const asset = AssetFactory.create(); mocks.assetJob.getForOcr.mockResolvedValue({ visibility: AssetVisibility.Timeline, previewFile: null }); - expect(await sut.handleOcr({ id: assetStub.noResizePath.id })).toEqual(JobStatus.Failed); + expect(await sut.handleOcr({ id: asset.id })).toEqual(JobStatus.Failed); expect(mocks.ocr.upsert).not.toHaveBeenCalled(); expect(mocks.machineLearning.ocr).not.toHaveBeenCalled(); }); it('should save the returned objects', async () => { + const asset = AssetFactory.create(); mocks.machineLearning.ocr.mockResolvedValue({ box: [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160], boxScore: [0.9, 0.8], @@ -86,7 +90,7 @@ describe(OcrService.name, () => { textScore: [0.95, 0.85], }); - expect(await sut.handleOcr({ id: assetStub.image.id })).toEqual(JobStatus.Success); + expect(await sut.handleOcr({ id: asset.id })).toEqual(JobStatus.Success); expect(mocks.machineLearning.ocr).toHaveBeenCalledWith( '/uploads/user-id/thumbs/path.jpg', @@ -98,10 +102,10 @@ describe(OcrService.name, () => { }), ); expect(mocks.ocr.upsert).toHaveBeenCalledWith( - assetStub.image.id, + asset.id, [ { - assetId: assetStub.image.id, + assetId: asset.id, boxScore: 0.9, text: 'One Two Three', textScore: 0.95, @@ -115,7 +119,7 @@ describe(OcrService.name, () => { y4: 80, }, { - assetId: assetStub.image.id, + assetId: asset.id, boxScore: 0.8, text: 'Four Five', textScore: 0.85, @@ -134,6 +138,7 @@ describe(OcrService.name, () => { }); it('should apply config settings', async () => { + const asset = AssetFactory.create(); mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { enabled: true, @@ -148,7 +153,7 @@ describe(OcrService.name, () => { }); mockOcrResult(); - expect(await sut.handleOcr({ id: assetStub.image.id })).toEqual(JobStatus.Success); + expect(await sut.handleOcr({ id: asset.id })).toEqual(JobStatus.Success); expect(mocks.machineLearning.ocr).toHaveBeenCalledWith( '/uploads/user-id/thumbs/path.jpg', @@ -159,16 +164,17 @@ describe(OcrService.name, () => { maxResolution: 1500, }), ); - expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, [], ''); + expect(mocks.ocr.upsert).toHaveBeenCalledWith(asset.id, [], ''); }); it('should skip invisible assets', async () => { + const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build(); mocks.assetJob.getForOcr.mockResolvedValue({ visibility: AssetVisibility.Hidden, - previewFile: assetStub.image.files[1].path, + previewFile: asset.files[0].path, }); - expect(await sut.handleOcr({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.Skipped); + expect(await sut.handleOcr({ id: asset.id })).toEqual(JobStatus.Skipped); expect(mocks.machineLearning.ocr).not.toHaveBeenCalled(); expect(mocks.ocr.upsert).not.toHaveBeenCalled(); @@ -177,7 +183,7 @@ describe(OcrService.name, () => { it('should fail if asset could not be found', async () => { mocks.assetJob.getForOcr.mockResolvedValue(void 0); - expect(await sut.handleOcr({ id: assetStub.image.id })).toEqual(JobStatus.Failed); + expect(await sut.handleOcr({ id: 'non-existent' })).toEqual(JobStatus.Failed); expect(mocks.machineLearning.ocr).not.toHaveBeenCalled(); expect(mocks.ocr.upsert).not.toHaveBeenCalled(); @@ -185,79 +191,84 @@ describe(OcrService.name, () => { describe('search tokenization', () => { it('should generate bigrams for Chinese text', async () => { + const asset = AssetFactory.create(); mockOcrResult('機器學習'); - await sut.handleOcr({ id: assetStub.image.id }); + await sut.handleOcr({ id: asset.id }); - expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), '機器 器學 學習'); + expect(mocks.ocr.upsert).toHaveBeenCalledWith(asset.id, expect.any(Array), '機器 器學 學習'); }); it('should generate bigrams for Japanese text', async () => { + const asset = AssetFactory.create(); mockOcrResult('テスト'); - await sut.handleOcr({ id: assetStub.image.id }); + await sut.handleOcr({ id: asset.id }); - expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), 'テス スト'); + expect(mocks.ocr.upsert).toHaveBeenCalledWith(asset.id, expect.any(Array), 'テス スト'); }); it('should generate bigrams for Korean text', async () => { + const asset = AssetFactory.create(); mockOcrResult('한국어'); - await sut.handleOcr({ id: assetStub.image.id }); + await sut.handleOcr({ id: asset.id }); - expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), '한국 국어'); + expect(mocks.ocr.upsert).toHaveBeenCalledWith(asset.id, expect.any(Array), '한국 국어'); }); it('should pass through Latin text unchanged', async () => { + const asset = AssetFactory.create(); mockOcrResult('Hello World'); - await sut.handleOcr({ id: assetStub.image.id }); + await sut.handleOcr({ id: asset.id }); - expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), 'Hello World'); + expect(mocks.ocr.upsert).toHaveBeenCalledWith(asset.id, expect.any(Array), 'Hello World'); }); it('should handle mixed CJK and Latin text', async () => { + const asset = AssetFactory.create(); mockOcrResult('機器學習Model'); - await sut.handleOcr({ id: assetStub.image.id }); + await sut.handleOcr({ id: asset.id }); - expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), '機器 器學 學習 Model'); + expect(mocks.ocr.upsert).toHaveBeenCalledWith(asset.id, expect.any(Array), '機器 器學 學習 Model'); }); it('should handle year followed by CJK', async () => { + const asset = AssetFactory.create(); mockOcrResult('2024年レポート'); - await sut.handleOcr({ id: assetStub.image.id }); + await sut.handleOcr({ id: asset.id }); - expect(mocks.ocr.upsert).toHaveBeenCalledWith( - assetStub.image.id, - expect.any(Array), - '2024 年レ レポ ポー ート', - ); + expect(mocks.ocr.upsert).toHaveBeenCalledWith(asset.id, expect.any(Array), '2024 年レ レポ ポー ート'); }); it('should join multiple OCR boxes', async () => { + const asset = AssetFactory.create(); mockOcrResult('機器', 'Learning'); - await sut.handleOcr({ id: assetStub.image.id }); + await sut.handleOcr({ id: asset.id }); - expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), '機器 Learning'); + expect(mocks.ocr.upsert).toHaveBeenCalledWith(asset.id, expect.any(Array), '機器 Learning'); }); it('should normalize whitespace', async () => { + const asset = AssetFactory.create(); mockOcrResult(' Hello World '); - await sut.handleOcr({ id: assetStub.image.id }); + await sut.handleOcr({ id: asset.id }); - expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), 'Hello World'); + expect(mocks.ocr.upsert).toHaveBeenCalledWith(asset.id, expect.any(Array), 'Hello World'); }); it('should keep single CJK characters', async () => { + const asset = AssetFactory.create(); mockOcrResult('A', '中', 'B'); - await sut.handleOcr({ id: assetStub.image.id }); + await sut.handleOcr({ id: asset.id }); - expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), 'A 中 B'); + expect(mocks.ocr.upsert).toHaveBeenCalledWith(asset.id, expect.any(Array), 'A 中 B'); }); }); }); diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index b57a5e1072..0928b57f97 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -1,12 +1,12 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.dto'; -import { CacheControl, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum'; +import { AssetFileType, CacheControl, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum'; import { DetectedFaces } from 'src/repositories/machine-learning.repository'; import { FaceSearchResult } from 'src/repositories/search.repository'; import { PersonService } from 'src/services/person.service'; import { ImmichFileResponse } from 'src/utils/file'; -import { assetStub } from 'test/fixtures/asset.stub'; +import { AssetFactory } from 'test/factories/asset.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { personStub } from 'test/fixtures/person.stub'; @@ -261,7 +261,7 @@ describe(PersonService.name, () => { it("should update a person's thumbnailPath", async () => { mocks.person.update.mockResolvedValue(personStub.withName); mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([faceStub.face1.assetId])); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect( @@ -331,7 +331,7 @@ describe(PersonService.name, () => { await expect( sut.reassignFaces(authStub.admin, personStub.noName.id, { - data: [{ personId: personStub.withName.id, assetId: assetStub.image.id }], + data: [{ personId: personStub.withName.id, assetId: faceStub.face1.assetId }], }), ).resolves.toBeDefined(); @@ -352,9 +352,10 @@ describe(PersonService.name, () => { describe('getFacesById', () => { it('should get the bounding boxes for an asset', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([faceStub.face1.assetId])); + const asset = AssetFactory.from({ id: faceStub.face1.assetId }).exif().build(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.person.getFaces.mockResolvedValue([faceStub.primaryFace1]); - mocks.asset.getById.mockResolvedValue(assetStub.image); + mocks.asset.getById.mockResolvedValue(asset); await expect(sut.getFacesById(authStub.admin, { id: faceStub.face1.assetId })).resolves.toStrictEqual([ mapFaces(faceStub.primaryFace1, authStub.admin), ]); @@ -455,7 +456,8 @@ describe(PersonService.name, () => { }); it('should queue missing assets', async () => { - mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset])); await sut.handleQueueDetectFaces({ force: false }); @@ -464,13 +466,14 @@ describe(PersonService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetDetectFaces, - data: { id: assetStub.image.id }, + data: { id: asset.id }, }, ]); }); it('should queue all assets', async () => { - mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset])); mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.withName]); await sut.handleQueueDetectFaces({ force: true }); @@ -483,13 +486,14 @@ describe(PersonService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetDetectFaces, - data: { id: assetStub.image.id }, + data: { id: asset.id }, }, ]); }); it('should refresh all assets', async () => { - mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset])); await sut.handleQueueDetectFaces({ force: undefined }); @@ -501,16 +505,17 @@ describe(PersonService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetDetectFaces, - data: { id: assetStub.image.id }, + data: { id: asset.id }, }, ]); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.PersonCleanup }); }); it('should delete existing people and faces if forced', async () => { + const asset = AssetFactory.create(); mocks.person.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson])); mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); - mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([assetStub.image])); + mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset])); mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); mocks.person.deleteFaces.mockResolvedValue(); @@ -520,7 +525,7 @@ describe(PersonService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetDetectFaces, - data: { id: assetStub.image.id }, + data: { id: asset.id }, }, ]); expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]); @@ -718,26 +723,28 @@ describe(PersonService.name, () => { }); it('should skip when no resize path', async () => { - mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ ...assetStub.noResizePath, files: [] }); - await sut.handleDetectFaces({ id: assetStub.noResizePath.id }); + const asset = AssetFactory.create(); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); + await sut.handleDetectFaces({ id: asset.id }); expect(mocks.machineLearning.detectFaces).not.toHaveBeenCalled(); }); it('should handle no results', async () => { const start = Date.now(); + const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build(); mocks.machineLearning.detectFaces.mockResolvedValue({ imageHeight: 500, imageWidth: 400, faces: [] }); - mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ ...assetStub.image, files: [assetStub.image.files[1]] }); - await sut.handleDetectFaces({ id: assetStub.image.id }); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); + await sut.handleDetectFaces({ id: asset.id }); expect(mocks.machineLearning.detectFaces).toHaveBeenCalledWith( - '/uploads/user-id/thumbs/path.jpg', + asset.files[0].path, expect.objectContaining({ minScore: 0.7, modelName: 'buffalo_l' }), ); expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith({ - assetId: assetStub.image.id, + assetId: asset.id, facesRecognizedAt: expect.any(Date), }); const facesRecognizedAt = mocks.asset.upsertJobStatus.mock.calls[0][0].facesRecognizedAt as Date; @@ -745,14 +752,15 @@ describe(PersonService.name, () => { }); it('should create a face with no person and queue recognition job', async () => { + const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build(); mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); mocks.search.searchFaces.mockResolvedValue([{ ...faceStub.face1, distance: 0.7 }]); - mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ ...assetStub.image, files: [assetStub.image.files[1]] }); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); mocks.person.refreshFaces.mockResolvedValue(); - await sut.handleDetectFaces({ id: assetStub.image.id }); + await sut.handleDetectFaces({ id: asset.id }); - expect(mocks.person.refreshFaces).toHaveBeenCalledWith([face], [], [faceSearch]); + expect(mocks.person.refreshFaces).toHaveBeenCalledWith([{ ...face, assetId: asset.id }], [], [faceSearch]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FacialRecognitionQueueAll, data: { force: false } }, { name: JobName.FacialRecognition, data: { id: faceId } }, @@ -762,14 +770,11 @@ describe(PersonService.name, () => { }); it('should delete an existing face not among the new detected faces', async () => { + const asset = AssetFactory.from().face(faceStub.primaryFace1).file({ type: AssetFileType.Preview }).build(); mocks.machineLearning.detectFaces.mockResolvedValue({ faces: [], imageHeight: 500, imageWidth: 400 }); - mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ - ...assetStub.image, - faces: [faceStub.primaryFace1], - files: [assetStub.image.files[1]], - }); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); - await sut.handleDetectFaces({ id: assetStub.image.id }); + await sut.handleDetectFaces({ id: asset.id }); expect(mocks.person.refreshFaces).toHaveBeenCalledWith([], [faceStub.primaryFace1.id], []); expect(mocks.job.queueAll).not.toHaveBeenCalled(); @@ -778,17 +783,18 @@ describe(PersonService.name, () => { }); it('should add new face and delete an existing face not among the new detected faces', async () => { + const asset = AssetFactory.from().face(faceStub.primaryFace1).file({ type: AssetFileType.Preview }).build(); mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); - mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ - ...assetStub.image, - faces: [faceStub.primaryFace1], - files: [assetStub.image.files[1]], - }); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); mocks.person.refreshFaces.mockResolvedValue(); - await sut.handleDetectFaces({ id: assetStub.image.id }); + await sut.handleDetectFaces({ id: asset.id }); - expect(mocks.person.refreshFaces).toHaveBeenCalledWith([face], [faceStub.primaryFace1.id], [faceSearch]); + expect(mocks.person.refreshFaces).toHaveBeenCalledWith( + [{ ...face, assetId: asset.id }], + [faceStub.primaryFace1.id], + [faceSearch], + ); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FacialRecognitionQueueAll, data: { force: false } }, { name: JobName.FacialRecognition, data: { id: faceId } }, @@ -798,15 +804,12 @@ describe(PersonService.name, () => { }); it('should add embedding to matching metadata face', async () => { + const asset = AssetFactory.from().face(faceStub.fromExif1).file({ type: AssetFileType.Preview }).build(); mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); - mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ - ...assetStub.image, - faces: [faceStub.fromExif1], - files: [assetStub.image.files[1]], - }); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); mocks.person.refreshFaces.mockResolvedValue(); - await sut.handleDetectFaces({ id: assetStub.image.id }); + await sut.handleDetectFaces({ id: asset.id }); expect(mocks.person.refreshFaces).toHaveBeenCalledWith( [], @@ -819,16 +822,13 @@ describe(PersonService.name, () => { }); it('should not add embedding to non-matching metadata face', async () => { + const asset = AssetFactory.from().face(faceStub.fromExif2).file({ type: AssetFileType.Preview }).build(); mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); - mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ - ...assetStub.image, - faces: [faceStub.fromExif2], - files: [assetStub.image.files[1]], - }); + mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); - await sut.handleDetectFaces({ id: assetStub.image.id }); + await sut.handleDetectFaces({ id: asset.id }); - expect(mocks.person.refreshFaces).toHaveBeenCalledWith([face], [], [faceSearch]); + expect(mocks.person.refreshFaces).toHaveBeenCalledWith([{ ...face, assetId: asset.id }], [], [faceSearch]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FacialRecognitionQueueAll, data: { force: false } }, { name: JobName.FacialRecognition, data: { id: faceId } }, diff --git a/server/src/services/shared-link.service.spec.ts b/server/src/services/shared-link.service.spec.ts index 8c0e336d5b..d4f11f37a4 100644 --- a/server/src/services/shared-link.service.spec.ts +++ b/server/src/services/shared-link.service.spec.ts @@ -1,10 +1,10 @@ import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; -import _ from 'lodash'; import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { SharedLinkType } from 'src/enum'; import { SharedLinkService } from 'src/services/shared-link.service'; import { AlbumFactory } from 'test/factories/album.factory'; -import { assetStub } from 'test/fixtures/asset.stub'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { SharedLinkFactory } from 'test/factories/shared-link.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { sharedLinkResponseStub, sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { factory } from 'test/small.factory'; @@ -142,12 +142,13 @@ describe(SharedLinkService.name, () => { }); it('should create an individual shared link', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + const asset = AssetFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); await sut.create(authStub.admin, { type: SharedLinkType.Individual, - assetIds: [assetStub.image.id], + assetIds: [asset.id], showMetadata: true, allowDownload: true, allowUpload: true, @@ -155,7 +156,7 @@ describe(SharedLinkService.name, () => { expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, - new Set([assetStub.image.id]), + new Set([asset.id]), false, ); expect(mocks.sharedLink.create).toHaveBeenCalledWith({ @@ -165,7 +166,7 @@ describe(SharedLinkService.name, () => { allowDownload: true, slug: null, allowUpload: true, - assetIds: [assetStub.image.id], + assetIds: [asset.id], description: null, expiresAt: null, showExif: true, @@ -174,12 +175,13 @@ describe(SharedLinkService.name, () => { }); it('should create a shared link with allowDownload set to false when showMetadata is false', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + const asset = AssetFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); await sut.create(authStub.admin, { type: SharedLinkType.Individual, - assetIds: [assetStub.image.id], + assetIds: [asset.id], showMetadata: false, allowDownload: true, allowUpload: true, @@ -187,7 +189,7 @@ describe(SharedLinkService.name, () => { expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, - new Set([assetStub.image.id]), + new Set([asset.id]), false, ); expect(mocks.sharedLink.create).toHaveBeenCalledWith({ @@ -196,7 +198,7 @@ describe(SharedLinkService.name, () => { albumId: null, allowDownload: false, allowUpload: true, - assetIds: [assetStub.image.id], + assetIds: [asset.id], description: null, expiresAt: null, showExif: false, @@ -263,25 +265,28 @@ describe(SharedLinkService.name, () => { }); it('should add assets to a shared link', async () => { - mocks.sharedLink.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); - mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); - mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.individual); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-3'])); + const asset = AssetFactory.create(); + const sharedLink = SharedLinkFactory.from().asset(asset).build(); + const newAsset = AssetFactory.create(); + mocks.sharedLink.get.mockResolvedValue(sharedLink); + mocks.sharedLink.create.mockResolvedValue(sharedLink); + mocks.sharedLink.update.mockResolvedValue(sharedLink); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([newAsset.id])); await expect( - sut.addAssets(authStub.admin, 'link-1', { assetIds: [assetStub.image.id, 'asset-2', 'asset-3'] }), + sut.addAssets(authStub.admin, sharedLink.id, { assetIds: [asset.id, 'asset-2', newAsset.id] }), ).resolves.toEqual([ - { assetId: assetStub.image.id, success: false, error: AssetIdErrorReason.DUPLICATE }, + { assetId: asset.id, success: false, error: AssetIdErrorReason.DUPLICATE }, { assetId: 'asset-2', success: false, error: AssetIdErrorReason.NO_PERMISSION }, - { assetId: 'asset-3', success: true }, + { assetId: newAsset.id, success: true }, ]); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledTimes(1); expect(mocks.sharedLink.update).toHaveBeenCalled(); expect(mocks.sharedLink.update).toHaveBeenCalledWith({ - ...sharedLinkStub.individual, + ...sharedLink, slug: null, - assetIds: ['asset-3'], + assetIds: [newAsset.id], }); }); }); @@ -296,20 +301,22 @@ describe(SharedLinkService.name, () => { }); it('should remove assets from a shared link', async () => { - mocks.sharedLink.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); - mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); - mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.individual); - mocks.sharedLinkAsset.remove.mockResolvedValue([assetStub.image.id]); + const asset = AssetFactory.create(); + const sharedLink = SharedLinkFactory.from().asset(asset).build(); + mocks.sharedLink.get.mockResolvedValue(sharedLink); + mocks.sharedLink.create.mockResolvedValue(sharedLink); + mocks.sharedLink.update.mockResolvedValue(sharedLink); + mocks.sharedLinkAsset.remove.mockResolvedValue([asset.id]); await expect( - sut.removeAssets(authStub.admin, 'link-1', { assetIds: [assetStub.image.id, 'asset-2'] }), + sut.removeAssets(authStub.admin, sharedLink.id, { assetIds: [asset.id, 'asset-2'] }), ).resolves.toEqual([ - { assetId: assetStub.image.id, success: true }, + { assetId: asset.id, success: true }, { assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND }, ]); - expect(mocks.sharedLinkAsset.remove).toHaveBeenCalledWith('link-1', [assetStub.image.id, 'asset-2']); - expect(mocks.sharedLink.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [] }); + expect(mocks.sharedLinkAsset.remove).toHaveBeenCalledWith(sharedLink.id, [asset.id, 'asset-2']); + expect(mocks.sharedLink.update).toHaveBeenCalledWith(expect.objectContaining({ assets: [] })); }); }); @@ -333,7 +340,7 @@ describe(SharedLinkService.name, () => { await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({ description: '1 shared photos & videos', - imageUrl: `https://my.immich.app/api/assets/asset-id/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0`, + imageUrl: `https://my.immich.app/api/assets/${sharedLinkStub.individual.assets[0].id}/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0`, title: 'Public Share', }); diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index b3af5cd15f..6bd0a3c9b2 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -1,8 +1,8 @@ import { SystemConfig } from 'src/config'; -import { ImmichWorker, JobName, JobStatus } from 'src/enum'; +import { AssetFileType, AssetVisibility, ImmichWorker, JobName, JobStatus } from 'src/enum'; import { SmartInfoService } from 'src/services/smart-info.service'; import { getCLIPModelInfo } from 'src/utils/misc'; -import { assetStub } from 'test/fixtures/asset.stub'; +import { AssetFactory } from 'test/factories/asset.factory'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; @@ -13,7 +13,7 @@ describe(SmartInfoService.name, () => { beforeEach(() => { ({ sut, mocks } = newTestService(SmartInfoService)); - mocks.asset.getByIds.mockResolvedValue([assetStub.image]); + mocks.asset.getByIds.mockResolvedValue([AssetFactory.create()]); mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices); }); @@ -155,25 +155,23 @@ describe(SmartInfoService.name, () => { }); it('should queue the assets without clip embeddings', async () => { - mocks.assetJob.streamForEncodeClip.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForEncodeClip.mockReturnValue(makeStream([asset])); await sut.handleQueueEncodeClip({ force: false }); - expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.SmartSearch, data: { id: assetStub.image.id } }, - ]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SmartSearch, data: { id: asset.id } }]); expect(mocks.assetJob.streamForEncodeClip).toHaveBeenCalledWith(false); expect(mocks.database.setDimensionSize).not.toHaveBeenCalled(); }); it('should queue all the assets', async () => { - mocks.assetJob.streamForEncodeClip.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForEncodeClip.mockReturnValue(makeStream([asset])); await sut.handleQueueEncodeClip({ force: true }); - expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.SmartSearch, data: { id: assetStub.image.id } }, - ]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SmartSearch, data: { id: asset.id } }]); expect(mocks.assetJob.streamForEncodeClip).toHaveBeenCalledWith(true); expect(mocks.database.setDimensionSize).toHaveBeenCalledExactlyOnceWith(512); }); @@ -190,34 +188,36 @@ describe(SmartInfoService.name, () => { }); it('should skip assets without a resize path', async () => { - mocks.assetJob.getForClipEncoding.mockResolvedValue({ ...assetStub.noResizePath, files: [] }); + const asset = AssetFactory.create(); + mocks.assetJob.getForClipEncoding.mockResolvedValue(asset); - expect(await sut.handleEncodeClip({ id: assetStub.noResizePath.id })).toEqual(JobStatus.Failed); + expect(await sut.handleEncodeClip({ id: asset.id })).toEqual(JobStatus.Failed); expect(mocks.search.upsert).not.toHaveBeenCalled(); expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled(); }); it('should save the returned objects', async () => { + const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build(); mocks.machineLearning.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]'); - mocks.assetJob.getForClipEncoding.mockResolvedValue({ ...assetStub.image, files: [assetStub.image.files[1]] }); + mocks.assetJob.getForClipEncoding.mockResolvedValue(asset); - expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.Success); + expect(await sut.handleEncodeClip({ id: asset.id })).toEqual(JobStatus.Success); expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith( - '/uploads/user-id/thumbs/path.jpg', + asset.files[0].path, expect.objectContaining({ modelName: 'ViT-B-32__openai' }), ); - expect(mocks.search.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]'); + expect(mocks.search.upsert).toHaveBeenCalledWith(asset.id, '[0.01, 0.02, 0.03]'); }); it('should skip invisible assets', async () => { - mocks.assetJob.getForClipEncoding.mockResolvedValue({ - ...assetStub.livePhotoMotionAsset, - files: [assetStub.image.files[1]], - }); + const asset = AssetFactory.from({ visibility: AssetVisibility.Hidden }) + .file({ type: AssetFileType.Preview }) + .build(); + mocks.assetJob.getForClipEncoding.mockResolvedValue(asset); - expect(await sut.handleEncodeClip({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.Skipped); + expect(await sut.handleEncodeClip({ id: asset.id })).toEqual(JobStatus.Skipped); expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled(); expect(mocks.search.upsert).not.toHaveBeenCalled(); @@ -226,25 +226,26 @@ describe(SmartInfoService.name, () => { it('should fail if asset could not be found', async () => { mocks.assetJob.getForClipEncoding.mockResolvedValue(void 0); - expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.Failed); + expect(await sut.handleEncodeClip({ id: 'non-existent' })).toEqual(JobStatus.Failed); expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled(); expect(mocks.search.upsert).not.toHaveBeenCalled(); }); it('should wait for database', async () => { + const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build(); mocks.machineLearning.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]'); mocks.database.isBusy.mockReturnValue(true); - mocks.assetJob.getForClipEncoding.mockResolvedValue({ ...assetStub.image, files: [assetStub.image.files[1]] }); + mocks.assetJob.getForClipEncoding.mockResolvedValue(asset); - expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.Success); + expect(await sut.handleEncodeClip({ id: asset.id })).toEqual(JobStatus.Success); expect(mocks.database.wait).toHaveBeenCalledWith(512); expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith( - '/uploads/user-id/thumbs/path.jpg', + asset.files[0].path, expect.objectContaining({ modelName: 'ViT-B-32__openai' }), ); - expect(mocks.search.upsert).toHaveBeenCalledWith(assetStub.image.id, '[0.01, 0.02, 0.03]'); + expect(mocks.search.upsert).toHaveBeenCalledWith(asset.id, '[0.01, 0.02, 0.03]'); }); }); diff --git a/server/src/services/stack.service.spec.ts b/server/src/services/stack.service.spec.ts index 1dc87f4348..fa30ba39e3 100644 --- a/server/src/services/stack.service.spec.ts +++ b/server/src/services/stack.service.spec.ts @@ -1,6 +1,7 @@ import { BadRequestException } from '@nestjs/common'; import { StackService } from 'src/services/stack.service'; -import { assetStub, stackStub } from 'test/fixtures/asset.stub'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { stackStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -19,38 +20,36 @@ describe(StackService.name, () => { describe('search', () => { it('should search stacks', async () => { - mocks.stack.search.mockResolvedValue([stackStub('stack-id', [assetStub.image])]); + const asset = AssetFactory.create(); + mocks.stack.search.mockResolvedValue([stackStub('stack-id', [asset])]); - await sut.search(authStub.admin, { primaryAssetId: assetStub.image.id }); + await sut.search(authStub.admin, { primaryAssetId: asset.id }); expect(mocks.stack.search).toHaveBeenCalledWith({ ownerId: authStub.admin.user.id, - primaryAssetId: assetStub.image.id, + primaryAssetId: asset.id, }); }); }); describe('create', () => { it('should require asset.update permissions', async () => { - await expect( - sut.create(authStub.admin, { assetIds: [assetStub.image.id, assetStub.image1.id] }), - ).rejects.toBeInstanceOf(BadRequestException); + const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()]; + await expect(sut.create(authStub.admin, { assetIds: [primaryAsset.id, asset.id] })).rejects.toBeInstanceOf( + BadRequestException, + ); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalled(); expect(mocks.stack.create).not.toHaveBeenCalled(); }); it('should create a stack', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id, assetStub.image1.id])); - mocks.stack.create.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); - await expect( - sut.create(authStub.admin, { assetIds: [assetStub.image.id, assetStub.image1.id] }), - ).resolves.toEqual({ + const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()]; + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([primaryAsset.id, asset.id])); + mocks.stack.create.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset])); + await expect(sut.create(authStub.admin, { assetIds: [primaryAsset.id, asset.id] })).resolves.toEqual({ id: 'stack-id', - primaryAssetId: assetStub.image.id, - assets: [ - expect.objectContaining({ id: assetStub.image.id }), - expect.objectContaining({ id: assetStub.image1.id }), - ], + primaryAssetId: primaryAsset.id, + assets: [expect.objectContaining({ id: primaryAsset.id }), expect.objectContaining({ id: asset.id })], }); expect(mocks.event.emit).toHaveBeenCalledWith('StackCreate', { @@ -79,16 +78,14 @@ describe(StackService.name, () => { }); it('should get stack', async () => { + const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()]; mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); - mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); + mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset])); await expect(sut.get(authStub.admin, 'stack-id')).resolves.toEqual({ id: 'stack-id', - primaryAssetId: assetStub.image.id, - assets: [ - expect.objectContaining({ id: assetStub.image.id }), - expect.objectContaining({ id: assetStub.image1.id }), - ], + primaryAssetId: primaryAsset.id, + assets: [expect.objectContaining({ id: primaryAsset.id }), expect.objectContaining({ id: asset.id })], }); expect(mocks.access.stack.checkOwnerAccess).toHaveBeenCalled(); expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); @@ -115,8 +112,9 @@ describe(StackService.name, () => { }); it('should fail if the provided primary asset id is not in the stack', async () => { + const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()]; mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); - mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); + mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset])); await expect(sut.update(authStub.admin, 'stack-id', { primaryAssetId: 'unknown-asset' })).rejects.toBeInstanceOf( BadRequestException, @@ -128,16 +126,17 @@ describe(StackService.name, () => { }); it('should update stack', async () => { + const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()]; mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); - mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); - mocks.stack.update.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); + mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset])); + mocks.stack.update.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset])); - await sut.update(authStub.admin, 'stack-id', { primaryAssetId: assetStub.image1.id }); + await sut.update(authStub.admin, 'stack-id', { primaryAssetId: asset.id }); expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); expect(mocks.stack.update).toHaveBeenCalledWith('stack-id', { id: 'stack-id', - primaryAssetId: assetStub.image1.id, + primaryAssetId: asset.id, }); expect(mocks.event.emit).toHaveBeenCalledWith('StackUpdate', { stackId: 'stack-id', @@ -214,24 +213,26 @@ describe(StackService.name, () => { }); it('should fail if the assetId is the primaryAssetId', async () => { + const asset = AssetFactory.create(); mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); - mocks.stack.getForAssetRemoval.mockResolvedValue({ id: 'stack-id', primaryAssetId: assetStub.image.id }); + mocks.stack.getForAssetRemoval.mockResolvedValue({ id: 'stack-id', primaryAssetId: asset.id }); - await expect( - sut.removeAsset(authStub.admin, { id: 'stack-id', assetId: assetStub.image.id }), - ).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.removeAsset(authStub.admin, { id: 'stack-id', assetId: asset.id })).rejects.toBeInstanceOf( + BadRequestException, + ); expect(mocks.asset.update).not.toHaveBeenCalled(); expect(mocks.event.emit).not.toHaveBeenCalled(); }); it("should update the asset to nullify it's stack-id", async () => { + const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()]; mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); - mocks.stack.getForAssetRemoval.mockResolvedValue({ id: 'stack-id', primaryAssetId: assetStub.image.id }); + mocks.stack.getForAssetRemoval.mockResolvedValue({ id: 'stack-id', primaryAssetId: primaryAsset.id }); - await sut.removeAsset(authStub.admin, { id: 'stack-id', assetId: assetStub.image1.id }); + await sut.removeAsset(authStub.admin, { id: 'stack-id', assetId: asset.id }); - expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.image1.id, stackId: null }); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, stackId: null }); expect(mocks.event.emit).toHaveBeenCalledWith('StackUpdate', { stackId: 'stack-id', userId: authStub.admin.user.id, diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index 2b3e9d3f9f..a7ac1e0301 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -478,9 +478,9 @@ describe(StorageTemplateService.name, () => { mocks.user.getList.mockResolvedValue([userStub.user1]); mocks.move.create.mockResolvedValue({ id: '123', - entityId: assetStub.image.id, + entityId: asset.id, pathType: AssetPathType.Original, - oldPath: assetStub.image.originalPath, + oldPath: asset.originalPath, newPath, }); diff --git a/server/src/services/sync.service.spec.ts b/server/src/services/sync.service.spec.ts index 5b50340a9f..47438cd059 100644 --- a/server/src/services/sync.service.spec.ts +++ b/server/src/services/sync.service.spec.ts @@ -1,5 +1,6 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { SyncService } from 'src/services/sync.service'; +import { AssetFactory } from 'test/factories/asset.factory'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { factory } from 'test/small.factory'; @@ -60,10 +61,9 @@ describe(SyncService.name, () => { }); it('should return a response requiring a full sync when there are too many changes', async () => { + const asset = AssetFactory.create(); mocks.partner.getAll.mockResolvedValue([]); - mocks.asset.getChangedDeltaSync.mockResolvedValue( - Array.from({ length: 10_000 }).fill(assetStub.image), - ); + mocks.asset.getChangedDeltaSync.mockResolvedValue(Array.from({ length: 10_000 }).fill(asset)); await expect( sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }), ).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] }); @@ -72,14 +72,15 @@ describe(SyncService.name, () => { }); it('should return a response with changes and deletions', async () => { + const asset = AssetFactory.create({ ownerId: authStub.user1.user.id }); mocks.partner.getAll.mockResolvedValue([]); - mocks.asset.getChangedDeltaSync.mockResolvedValue([assetStub.image1]); + mocks.asset.getChangedDeltaSync.mockResolvedValue([asset]); mocks.audit.getAfter.mockResolvedValue([assetStub.external.id]); await expect( sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }), ).resolves.toEqual({ needsFullSync: false, - upserted: [mapAsset(assetStub.image1, mapAssetOpts)], + upserted: [mapAsset(asset, mapAssetOpts)], deleted: [assetStub.external.id], }); expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(1); diff --git a/server/src/services/view.service.spec.ts b/server/src/services/view.service.spec.ts index 86bfcef734..7b26fb5eb3 100644 --- a/server/src/services/view.service.spec.ts +++ b/server/src/services/view.service.spec.ts @@ -1,6 +1,6 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { ViewService } from 'src/services/view.service'; -import { assetStub } from 'test/fixtures/asset.stub'; +import { AssetFactory } from 'test/factories/asset.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { newTestService, ServiceMocks } from 'test/utils'; @@ -32,8 +32,8 @@ describe(ViewService.name, () => { it('should return assets by original path', async () => { const path = '/asset'; - const asset1 = { ...assetStub.image, originalPath: '/asset/path1' }; - const asset2 = { ...assetStub.image, originalPath: '/asset/path2' }; + const asset1 = AssetFactory.create({ originalPath: '/asset/path1' }); + const asset2 = AssetFactory.create({ originalPath: '/asset/path2' }); const mockAssets = [asset1, asset2]; diff --git a/server/test/factories/asset.factory.ts b/server/test/factories/asset.factory.ts index 8cbf704abf..8c9a9a5840 100644 --- a/server/test/factories/asset.factory.ts +++ b/server/test/factories/asset.factory.ts @@ -15,6 +15,7 @@ export class AssetFactory { #assetExif?: AssetExifFactory; #files: AssetFileFactory[] = []; #edits: AssetEditFactory[] = []; + #faces: Selectable[] = []; private constructor(private readonly value: Selectable) { value.ownerId ??= newUuid(); @@ -82,6 +83,11 @@ export class AssetFactory { return this; } + face(dto: Selectable) { + this.#faces.push(dto); + return this; + } + file(dto: AssetFileLike = {}, builder?: FactoryBuilder) { this.#files.push(build(AssetFileFactory.from(dto).asset(this.value), builder)); return this; @@ -120,7 +126,8 @@ export class AssetFactory { exifInfo: exif as NonNullable, files: this.#files.map((file) => file.build()), edits: this.#edits.map((edit) => edit.build()), - faces: [] as Selectable[], + faces: this.#faces, + stack: null, }; } } diff --git a/server/test/factories/shared-link.factory.ts b/server/test/factories/shared-link.factory.ts index 585b43dd84..5ac5f1756b 100644 --- a/server/test/factories/shared-link.factory.ts +++ b/server/test/factories/shared-link.factory.ts @@ -2,14 +2,16 @@ import { Selectable } from 'kysely'; import { SharedLinkType } from 'src/enum'; import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; import { AlbumFactory } from 'test/factories/album.factory'; +import { AssetFactory } from 'test/factories/asset.factory'; import { build } from 'test/factories/builder.factory'; -import { AlbumLike, FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types'; +import { AlbumLike, AssetLike, FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types'; import { UserFactory } from 'test/factories/user.factory'; import { factory, newDate, newUuid } from 'test/small.factory'; export class SharedLinkFactory { #owner: UserFactory; #album?: AlbumFactory; + #assets: AssetFactory[] = []; private constructor(private readonly value: Selectable) { value.userId ??= newUuid(); @@ -52,12 +54,18 @@ export class SharedLinkFactory { return this; } + asset(dto: AssetLike = {}, builder?: FactoryBuilder) { + const asset = build(AssetFactory.from(dto), builder); + this.#assets.push(asset); + return this; + } + build() { return { ...this.value, owner: this.#owner.build(), - album: this.#album?.build(), - assets: [], + album: this.#album?.build() ?? null, + assets: this.#assets.map((asset) => asset.build()), }; } } diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 3c89056f37..fd0c6cf002 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -55,45 +55,6 @@ export const assetStub = { isEdited: false, ...asset, }), - noResizePath: Object.freeze({ - id: 'asset-id', - status: AssetStatus.Active, - originalFileName: 'IMG_123.jpg', - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/data/library/IMG_123.jpg', - files: [thumbnailFile], - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Image, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - duration: null, - livePhotoVideo: null, - livePhotoVideoId: null, - sharedLinks: [], - faces: [], - exifInfo: {} as Exif, - deletedAt: null, - isExternal: false, - duplicateId: null, - isOffline: false, - libraryId: null, - stackId: null, - updateId: '42', - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: false, - }), primaryImage: Object.freeze({ id: 'primary-asset-id', @@ -144,53 +105,6 @@ export const assetStub = { isEdited: false, }), - image: Object.freeze({ - id: 'asset-id', - status: AssetStatus.Active, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.jpg', - files, - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Image, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2025-01-01T01:02:03.456Z'), - isFavorite: true, - duration: null, - isExternal: false, - livePhotoVideo: null, - livePhotoVideoId: null, - updateId: 'foo', - libraryId: null, - stackId: null, - sharedLinks: [], - originalFileName: 'asset-id.jpg', - faces: [], - deletedAt: null, - exifInfo: { - fileSizeInByte: 5000, - exifImageHeight: 3840, - exifImageWidth: 2160, - } as Exif, - duplicateId: null, - isOffline: false, - stack: null, - orientation: '', - projectionType: null, - height: null, - width: null, - visibility: AssetVisibility.Timeline, - edits: [], - isEdited: false, - }), - trashed: Object.freeze({ id: 'asset-id', deviceAssetId: 'device-asset-id', @@ -365,49 +279,6 @@ export const assetStub = { isEdited: false, }), - image1: Object.freeze({ - id: 'asset-id-1', - status: AssetStatus.Active, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.ext', - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Image, - files, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - deletedAt: null, - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - duration: null, - livePhotoVideo: null, - livePhotoVideoId: null, - isExternal: false, - sharedLinks: [], - originalFileName: 'asset-id.ext', - faces: [], - exifInfo: { - fileSizeInByte: 5000, - } as Exif, - duplicateId: null, - isOffline: false, - updateId: '42', - stackId: null, - libraryId: null, - stack: null, - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: false, - }), - video: Object.freeze({ id: 'asset-id', status: AssetStatus.Active, diff --git a/server/test/fixtures/face.stub.ts b/server/test/fixtures/face.stub.ts index 94a2dcff22..e01394e84f 100644 --- a/server/test/fixtures/face.stub.ts +++ b/server/test/fixtures/face.stub.ts @@ -1,13 +1,13 @@ import { SourceType } from 'src/enum'; -import { assetStub } from 'test/fixtures/asset.stub'; +import { AssetFactory } from 'test/factories/asset.factory'; import { personStub } from 'test/fixtures/person.stub'; export const faceStub = { face1: Object.freeze({ id: 'assetFaceId1', - assetId: assetStub.image.id, + assetId: 'asset-id', asset: { - ...assetStub.image, + ...AssetFactory.create({ id: 'asset-id' }), libraryId: null, updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', stackId: null, @@ -29,8 +29,8 @@ export const faceStub = { }), primaryFace1: Object.freeze({ id: 'assetFaceId2', - assetId: assetStub.image.id, - asset: assetStub.image, + assetId: 'asset-id', + asset: AssetFactory.create({ id: 'asset-id' }), personId: personStub.primaryPerson.id, person: personStub.primaryPerson, boundingBoxX1: 0, @@ -48,8 +48,8 @@ export const faceStub = { }), mergeFace1: Object.freeze({ id: 'assetFaceId3', - assetId: assetStub.image.id, - asset: assetStub.image, + assetId: 'asset-id', + asset: AssetFactory.create({ id: 'asset-id' }), personId: personStub.mergePerson.id, person: personStub.mergePerson, boundingBoxX1: 0, @@ -67,8 +67,8 @@ export const faceStub = { }), noPerson1: Object.freeze({ id: 'assetFaceId8', - assetId: assetStub.image.id, - asset: assetStub.image, + assetId: 'asset-id', + asset: AssetFactory.create({ id: 'asset-id' }), personId: null, person: null, boundingBoxX1: 0, @@ -86,8 +86,8 @@ export const faceStub = { }), noPerson2: Object.freeze({ id: 'assetFaceId9', - assetId: assetStub.image.id, - asset: assetStub.image, + assetId: 'asset-id', + asset: AssetFactory.create({ id: 'asset-id' }), personId: null, person: null, boundingBoxX1: 0, @@ -105,8 +105,8 @@ export const faceStub = { }), fromExif1: Object.freeze({ id: 'assetFaceId9', - assetId: assetStub.image.id, - asset: assetStub.image, + assetId: 'asset-id', + asset: AssetFactory.create({ id: 'asset-id' }), personId: personStub.randomPerson.id, person: personStub.randomPerson, boundingBoxX1: 100, @@ -123,8 +123,8 @@ export const faceStub = { }), fromExif2: Object.freeze({ id: 'assetFaceId9', - assetId: assetStub.image.id, - asset: assetStub.image, + assetId: 'asset-id', + asset: AssetFactory.create({ id: 'asset-id' }), personId: personStub.randomPerson.id, person: personStub.randomPerson, boundingBoxX1: 0, @@ -141,8 +141,8 @@ export const faceStub = { }), withBirthDate: Object.freeze({ id: 'assetFaceId10', - assetId: assetStub.image.id, - asset: assetStub.image, + assetId: 'asset-id', + asset: AssetFactory.create({ id: 'asset-id' }), personId: personStub.withBirthDate.id, person: personStub.withBirthDate, boundingBoxX1: 0, diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 859b6b6ae2..a42ff743bc 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -2,7 +2,7 @@ import { UserAdmin } from 'src/database'; import { MapAsset } from 'src/dtos/asset-response.dto'; import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto'; import { AssetStatus, AssetType, AssetVisibility, SharedLinkType } from 'src/enum'; -import { assetStub } from 'test/fixtures/asset.stub'; +import { AssetFactory } from 'test/factories/asset.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; @@ -31,7 +31,7 @@ export const sharedLinkStub = { albumId: null, album: null, description: null, - assets: [assetStub.image], + assets: [AssetFactory.create()], password: 'password', slug: null, }),