refactor: small tests (#26141)

This commit is contained in:
Daniel Dietzler 2026-02-11 17:49:00 +01:00 committed by GitHub
parent 222c90b7b7
commit e54678e0d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 721 additions and 764 deletions

View file

@ -9,12 +9,15 @@ import { AssetFile } from 'src/database';
import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto'; import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto';
import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto'; import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto';
import { MapAsset } from 'src/dtos/asset-response.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 { AssetFileType, AssetStatus, AssetType, AssetVisibility, CacheControl, JobName } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard'; import { AuthRequest } from 'src/middleware/auth.guard';
import { AssetMediaService } from 'src/services/asset-media.service'; import { AssetMediaService } from 'src/services/asset-media.service';
import { UploadBody } from 'src/types'; import { UploadBody } from 'src/types';
import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database'; import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';
import { ImmichFileResponse } from 'src/utils/file'; 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 { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { fileStub } from 'test/fixtures/file.stub'; import { fileStub } from 'test/fixtures/file.stub';
@ -470,12 +473,13 @@ describe(AssetMediaService.name, () => {
}); });
it('should handle a sidecar file', async () => { it('should handle a sidecar file', async () => {
mocks.asset.getById.mockResolvedValueOnce(assetStub.image); const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).build();
mocks.asset.create.mockResolvedValueOnce(assetStub.image); mocks.asset.getById.mockResolvedValueOnce(asset);
mocks.asset.create.mockResolvedValueOnce(asset);
await expect(sut.uploadAsset(authStub.user1, createDto, fileStub.photo, fileStub.photoSidecar)).resolves.toEqual({ await expect(sut.uploadAsset(authStub.user1, createDto, fileStub.photo, fileStub.photoSidecar)).resolves.toEqual({
status: AssetMediaStatus.CREATED, status: AssetMediaStatus.CREATED,
id: assetStub.image.id, id: asset.id,
}); });
expect(mocks.storage.utimes).toHaveBeenCalledWith( expect(mocks.storage.utimes).toHaveBeenCalledWith(
@ -501,13 +505,14 @@ describe(AssetMediaService.name, () => {
}); });
it('should download a file', async () => { it('should download a file', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); const asset = AssetFactory.create();
mocks.asset.getForOriginal.mockResolvedValue(assetStub.image); 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({ new ImmichFileResponse({
path: '/original/path.jpg', path: asset.originalPath,
fileName: 'asset-id.jpg', fileName: asset.originalFileName,
contentType: 'image/jpeg', contentType: 'image/jpeg',
cacheControl: CacheControl.PrivateWithCache, cacheControl: CacheControl.PrivateWithCache,
}), }),
@ -573,28 +578,16 @@ describe(AssetMediaService.name, () => {
}); });
it('should not return the unedited version if requested using a shared link', async () => { it('should not return the unedited version if requested using a shared link', async () => {
const editedAsset = { const fullsizeEdited = AssetFileFactory.create({ type: AssetFileType.FullSize, isEdited: true });
...assetStub.withCropEdit, const editedAsset = AssetFactory.from().edit({ action: AssetEditAction.Crop }).file(fullsizeEdited).build();
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',
});
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({ new ImmichFileResponse({
path: '/uploads/user-id/fullsize/edited.jpg', path: fullsizeEdited.path,
fileName: 'asset-id.jpg', fileName: editedAsset.originalFileName,
contentType: 'image/jpeg', contentType: 'image/jpeg',
cacheControl: CacheControl.PrivateWithCache, 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 () => { 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])); const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build();
mocks.asset.getForThumbnail.mockResolvedValue({ ...assetStub.image, path: '/path/to/preview.jpg' }); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getForThumbnail.mockResolvedValue({ ...asset, path: asset.files[0].path });
await expect( await expect(sut.viewThumbnail(authStub.admin, asset.id, { size: AssetMediaSize.THUMBNAIL })).resolves.toEqual(
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
).resolves.toEqual(
new ImmichFileResponse({ new ImmichFileResponse({
path: '/path/to/preview.jpg', path: asset.files[0].path,
cacheControl: CacheControl.PrivateWithCache, cacheControl: CacheControl.PrivateWithCache,
contentType: 'image/jpeg', contentType: 'image/jpeg',
fileName: 'asset-id_thumbnail.jpg', fileName: `IMG_${asset.id}_thumbnail.jpg`,
}), }),
); );
}); });
it('should get preview file', async () => { it('should get preview file', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build();
mocks.asset.getForThumbnail.mockResolvedValue({ ...assetStub.image, path: '/uploads/user-id/thumbs/path.jpg' }); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
await expect( mocks.asset.getForThumbnail.mockResolvedValue({ ...asset, path: asset.files[0].path });
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }), await expect(sut.viewThumbnail(authStub.admin, asset.id, { size: AssetMediaSize.PREVIEW })).resolves.toEqual(
).resolves.toEqual(
new ImmichFileResponse({ new ImmichFileResponse({
path: '/uploads/user-id/thumbs/path.jpg', path: asset.files[0].path,
cacheControl: CacheControl.PrivateWithCache, cacheControl: CacheControl.PrivateWithCache,
contentType: 'image/jpeg', contentType: 'image/jpeg',
fileName: 'asset-id_preview.jpg', fileName: `IMG_${asset.id}_preview.jpg`,
}), }),
); );
}); });
it('should get thumbnail file', async () => { it('should get thumbnail file', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); const asset = AssetFactory.from()
mocks.asset.getForThumbnail.mockResolvedValue({ ...assetStub.image, path: '/uploads/user-id/webp/path.ext' }); .file({ type: AssetFileType.Thumbnail, path: '/uploads/user-id/webp/path.ext' })
await expect( .build();
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }), mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
).resolves.toEqual( 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({ new ImmichFileResponse({
path: '/uploads/user-id/webp/path.ext', path: asset.files[0].path,
cacheControl: CacheControl.PrivateWithCache, cacheControl: CacheControl.PrivateWithCache,
contentType: 'application/octet-stream', 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 () => { it('should get original thumbnail by default', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); const asset = AssetFactory.from().file({ type: AssetFileType.Thumbnail }).build();
mocks.asset.getForThumbnail.mockResolvedValue({ mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
...assetStub.image, mocks.asset.getForThumbnail.mockResolvedValue({ ...asset, path: asset.files[0].path });
path: '/uploads/user-id/thumbs/original-thumbnail.jpg', await expect(sut.viewThumbnail(authStub.admin, asset.id, { size: AssetMediaSize.THUMBNAIL })).resolves.toEqual(
});
await expect(
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
).resolves.toEqual(
new ImmichFileResponse({ new ImmichFileResponse({
path: '/uploads/user-id/thumbs/original-thumbnail.jpg', path: asset.files[0].path,
cacheControl: CacheControl.PrivateWithCache, cacheControl: CacheControl.PrivateWithCache,
contentType: 'image/jpeg', 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 () => { it('should get edited thumbnail when edited=true', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); const asset = AssetFactory.from().file({ type: AssetFileType.Thumbnail, isEdited: true }).build();
mocks.asset.getForThumbnail.mockResolvedValue({ mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
...assetStub.image, mocks.asset.getForThumbnail.mockResolvedValue({ ...asset, path: asset.files[0].path });
path: '/uploads/user-id/thumbs/edited-thumbnail.jpg',
});
await expect( 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( ).resolves.toEqual(
new ImmichFileResponse({ new ImmichFileResponse({
path: '/uploads/user-id/thumbs/edited-thumbnail.jpg', path: asset.files[0].path,
cacheControl: CacheControl.PrivateWithCache, cacheControl: CacheControl.PrivateWithCache,
contentType: 'image/jpeg', 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 () => { it('should get original thumbnail when edited=false', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); const asset = AssetFactory.from().file({ type: AssetFileType.Thumbnail }).build();
mocks.asset.getForThumbnail.mockResolvedValue({ mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
...assetStub.image, mocks.asset.getForThumbnail.mockResolvedValue({ ...asset, path: asset.files[0].path });
path: '/uploads/user-id/thumbs/original-thumbnail.jpg',
});
await expect( 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( ).resolves.toEqual(
new ImmichFileResponse({ new ImmichFileResponse({
path: '/uploads/user-id/thumbs/original-thumbnail.jpg', path: asset.files[0].path,
cacheControl: CacheControl.PrivateWithCache, cacheControl: CacheControl.PrivateWithCache,
contentType: 'image/jpeg', 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 () => { it('should not return the unedited version if requested using a shared link', async () => {
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id])); const asset = AssetFactory.from().file({ type: AssetFileType.Thumbnail }).build();
mocks.asset.getForThumbnail.mockResolvedValue({ mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([asset.id]));
...assetStub.image, mocks.asset.getForThumbnail.mockResolvedValue({ ...asset, path: asset.files[0].path });
path: '/uploads/user-id/thumbs/edited-thumbnail.jpg',
});
await expect( await expect(
sut.viewThumbnail(authStub.adminSharedLink, assetStub.image.id, { sut.viewThumbnail(authStub.adminSharedLink, asset.id, {
size: AssetMediaSize.THUMBNAIL, size: AssetMediaSize.THUMBNAIL,
edited: true, edited: true,
}), }),
).resolves.toEqual( ).resolves.toEqual(
new ImmichFileResponse({ new ImmichFileResponse({
path: '/uploads/user-id/thumbs/edited-thumbnail.jpg', path: asset.files[0].path,
cacheControl: CacheControl.PrivateWithCache, cacheControl: CacheControl.PrivateWithCache,
contentType: 'image/jpeg', 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 () => { 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 () => { it('should return the encoded video path if available', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.hasEncodedVideo.id])); const asset = AssetFactory.create({ encodedVideoPath: '/path/to/encoded/video.mp4' });
mocks.asset.getForVideo.mockResolvedValue(assetStub.hasEncodedVideo); 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({ new ImmichFileResponse({
path: assetStub.hasEncodedVideo.encodedVideoPath!, path: asset.encodedVideoPath!,
cacheControl: CacheControl.PrivateWithCache, cacheControl: CacheControl.PrivateWithCache,
contentType: 'video/mp4', contentType: 'video/mp4',
}), }),

View file

@ -3,7 +3,7 @@ import { DateTime } from 'luxon';
import { MapAsset } from 'src/dtos/asset-response.dto'; import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto'; import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto';
import { AssetEditAction } from 'src/dtos/editing.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 { AssetStats } from 'src/repositories/asset.repository';
import { AssetService } from 'src/services/asset.service'; import { AssetService } from 'src/services/asset.service';
import { AssetFactory } from 'test/factories/asset.factory'; import { AssetFactory } from 'test/factories/asset.factory';
@ -79,7 +79,7 @@ describe(AssetService.name, () => {
describe('getRandom', () => { describe('getRandom', () => {
it('should get own random assets', async () => { it('should get own random assets', async () => {
mocks.partner.getAll.mockResolvedValue([]); mocks.partner.getAll.mockResolvedValue([]);
mocks.asset.getRandom.mockResolvedValue([assetStub.image]); mocks.asset.getRandom.mockResolvedValue([AssetFactory.create()]);
await sut.getRandom(authStub.admin, 1); await sut.getRandom(authStub.admin, 1);
@ -90,7 +90,7 @@ describe(AssetService.name, () => {
const partner = factory.partner({ inTimeline: false }); const partner = factory.partner({ inTimeline: false });
const auth = factory.auth({ user: { id: partner.sharedWithId } }); 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]); mocks.partner.getAll.mockResolvedValue([partner]);
await sut.getRandom(auth, 1); await sut.getRandom(auth, 1);
@ -102,7 +102,7 @@ describe(AssetService.name, () => {
const partner = factory.partner({ inTimeline: true }); const partner = factory.partner({ inTimeline: true });
const auth = factory.auth({ user: { id: partner.sharedWithId } }); 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]); mocks.partner.getAll.mockResolvedValue([partner]);
await sut.getRandom(auth, 1); await sut.getRandom(auth, 1);
@ -113,88 +113,90 @@ describe(AssetService.name, () => {
describe('get', () => { describe('get', () => {
it('should allow owner access', async () => { it('should allow owner access', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); const asset = AssetFactory.create();
mocks.asset.getById.mockResolvedValue(assetStub.image); 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( expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id, authStub.admin.user.id,
new Set([assetStub.image.id]), new Set([asset.id]),
undefined, undefined,
); );
}); });
it('should allow shared link access', async () => { it('should allow shared link access', async () => {
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id])); const asset = AssetFactory.create();
mocks.asset.getById.mockResolvedValue(assetStub.image); 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( expect(mocks.access.asset.checkSharedLinkAccess).toHaveBeenCalledWith(
authStub.adminSharedLink.sharedLink?.id, 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 () => { it('should strip metadata for shared link if exif is disabled', async () => {
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id])); const asset = AssetFactory.from().exif({ description: 'foo' }).build();
mocks.asset.getById.mockResolvedValue(assetStub.image); mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getById.mockResolvedValue(asset);
const result = await sut.get( const result = await sut.get(
{ ...authStub.adminSharedLink, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } }, { ...authStub.adminSharedLink, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } },
assetStub.image.id, asset.id,
); );
expect(result).toEqual(expect.objectContaining({ hasMetadata: false })); expect(result).toEqual(expect.objectContaining({ hasMetadata: false }));
expect(result).not.toHaveProperty('exifInfo'); expect(result).not.toHaveProperty('exifInfo');
expect(mocks.access.asset.checkSharedLinkAccess).toHaveBeenCalledWith( expect(mocks.access.asset.checkSharedLinkAccess).toHaveBeenCalledWith(
authStub.adminSharedLink.sharedLink?.id, authStub.adminSharedLink.sharedLink?.id,
new Set([assetStub.image.id]), new Set([asset.id]),
); );
}); });
it('should allow partner sharing access', async () => { it('should allow partner sharing access', async () => {
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([assetStub.image.id])); const asset = AssetFactory.create();
mocks.asset.getById.mockResolvedValue(assetStub.image); 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( expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set([asset.id]));
authStub.admin.user.id,
new Set([assetStub.image.id]),
);
}); });
it('should allow shared album access', async () => { it('should allow shared album access', async () => {
mocks.access.asset.checkAlbumAccess.mockResolvedValue(new Set([assetStub.image.id])); const asset = AssetFactory.create();
mocks.asset.getById.mockResolvedValue(assetStub.image); 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( expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set([asset.id]));
authStub.admin.user.id,
new Set([assetStub.image.id]),
);
}); });
it('should throw an error for no access', async () => { 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(); expect(mocks.asset.getById).not.toHaveBeenCalled();
}); });
it('should throw an error for an invalid shared link', async () => { 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.access.asset.checkOwnerAccess).not.toHaveBeenCalled();
expect(mocks.asset.getById).not.toHaveBeenCalled(); expect(mocks.asset.getById).not.toHaveBeenCalled();
}); });
it('should throw an error if the asset could not be found', async () => { 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 () => { it('should update the asset', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); const asset = AssetFactory.create();
mocks.asset.getById.mockResolvedValue(assetStub.image); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.update.mockResolvedValue(assetStub.image); 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 () => { it('should update the exif description', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); const asset = AssetFactory.create();
mocks.asset.getById.mockResolvedValue(assetStub.image); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.update.mockResolvedValue(assetStub.image); 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( expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
{ assetId: 'asset-1', description: 'Test description', lockedProperties: ['description'] }, { assetId: asset.id, description: 'Test description', lockedProperties: ['description'] },
{ lockedPropertiesBehavior: 'append' }, { lockedPropertiesBehavior: 'append' },
); );
}); });
it('should update the exif rating', async () => { it('should update the exif rating', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); const asset = AssetFactory.create();
mocks.asset.getById.mockResolvedValueOnce(assetStub.image); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.update.mockResolvedValueOnce(assetStub.image); 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( expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
{ {
assetId: 'asset-1', assetId: asset.id,
rating: 3, rating: 3,
lockedProperties: ['rating'], lockedProperties: ['rating'],
}, },
@ -346,10 +351,11 @@ describe(AssetService.name, () => {
it('should unlink a live video', async () => { it('should unlink a live video', async () => {
const auth = AuthFactory.create(); const auth = AuthFactory.create();
const asset = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id]));
mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoStillAsset); mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); 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 }); await sut.update(auth, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null });
@ -555,7 +561,11 @@ describe(AssetService.name, () => {
describe('handleAssetDeletion', () => { describe('handleAssetDeletion', () => {
it('should clean up files', async () => { 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); mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset);
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true }); await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
@ -565,12 +575,7 @@ describe(AssetService.name, () => {
{ {
name: JobName.FileDelete, name: JobName.FileDelete,
data: { data: {
files: [ files: [...asset.files.map(({ path }) => path), asset.originalPath],
'/uploads/user-id/webp/path.ext',
'/uploads/user-id/thumbs/path.jpg',
'/uploads/user-id/fullsize/path.webp',
asset.originalPath,
],
}, },
}, },
], ],
@ -656,14 +661,15 @@ describe(AssetService.name, () => {
}); });
it('should update usage', async () => { it('should update usage', async () => {
mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetStub.image); const asset = AssetFactory.from().exif({ fileSizeInByte: 5000 }).build();
await sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true }); mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset);
expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.image.ownerId, -5000); 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 () => { it('should fail if asset could not be found', async () => {
mocks.assetJob.getForAssetDeletion.mockResolvedValue(void 0); 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, JobStatus.Failed,
); );
}); });
@ -681,28 +687,30 @@ describe(AssetService.name, () => {
it('should return OCR data for an asset', async () => { it('should return OCR data for an asset', async () => {
const ocr1 = factory.assetOcr({ text: 'Hello World' }); const ocr1 = factory.assetOcr({ text: 'Hello World' });
const ocr2 = factory.assetOcr({ text: 'Test Image' }); 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.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( expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id, authStub.admin.user.id,
new Set(['asset-1']), new Set([asset.id]),
undefined, 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 () => { 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.ocr.getByAssetId.mockResolvedValue([]);
mocks.asset.getById.mockResolvedValue(assetStub.image); mocks.asset.getById.mockResolvedValue(asset);
await expect(sut.getOcr(authStub.admin, 'asset-1')).resolves.toEqual([]); 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', () => { describe('getUserAssetsByDeviceId', () => {
it('get assets by device id', async () => { 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)); mocks.asset.getAllByDeviceId.mockResolvedValue(assets.map((asset) => asset.deviceAssetId));

View file

@ -3,7 +3,6 @@ import { Readable } from 'node:stream';
import { DownloadResponseDto } from 'src/dtos/download.dto'; import { DownloadResponseDto } from 'src/dtos/download.dto';
import { DownloadService } from 'src/services/download.service'; import { DownloadService } from 'src/services/download.service';
import { AssetFactory } from 'test/factories/asset.factory'; import { AssetFactory } from 'test/factories/asset.factory';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { makeStream, newTestService, ServiceMocks } from 'test/utils'; import { makeStream, newTestService, ServiceMocks } from 'test/utils';
import { vitest } from 'vitest'; import { vitest } from 'vitest';
@ -37,21 +36,18 @@ describe(DownloadService.name, () => {
finalize: vitest.fn(), finalize: vitest.fn(),
stream: new Readable(), stream: new Readable(),
}; };
const asset = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id, 'unknown-asset']));
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.noResizePath, id: 'asset-1' }]); mocks.asset.getByIds.mockResolvedValue([asset]);
mocks.storage.createZipStream.mockReturnValue(archiveMock); 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, stream: archiveMock.stream,
}); });
expect(archiveMock.addFile).toHaveBeenCalledTimes(1); expect(archiveMock.addFile).toHaveBeenCalledTimes(1);
expect(archiveMock.addFile).toHaveBeenNthCalledWith( expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, asset.originalPath, asset.originalFileName);
1,
expect.stringContaining('/data/library/IMG_123.jpg'),
'IMG_123.jpg',
);
}); });
it('should log a warning if the original path could not be resolved', async () => { it('should log a warning if the original path could not be resolved', async () => {
@ -108,15 +104,14 @@ describe(DownloadService.name, () => {
finalize: vitest.fn(), finalize: vitest.fn(),
stream: new Readable(), 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.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id]));
mocks.asset.getByIds.mockResolvedValue([ mocks.asset.getByIds.mockResolvedValue([asset1, asset2]);
{ ...assetStub.noResizePath, id: 'asset-1' },
{ ...assetStub.noResizePath, id: 'asset-2' },
]);
mocks.storage.createZipStream.mockReturnValue(archiveMock); 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, stream: archiveMock.stream,
}); });
@ -131,15 +126,14 @@ describe(DownloadService.name, () => {
finalize: vitest.fn(), finalize: vitest.fn(),
stream: new Readable(), 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.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id]));
mocks.asset.getByIds.mockResolvedValue([ mocks.asset.getByIds.mockResolvedValue([asset2, asset1]);
{ ...assetStub.noResizePath, id: 'asset-2' },
{ ...assetStub.noResizePath, id: 'asset-1' },
]);
mocks.storage.createZipStream.mockReturnValue(archiveMock); 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, stream: archiveMock.stream,
}); });
@ -155,18 +149,17 @@ describe(DownloadService.name, () => {
stream: new Readable(), stream: new Readable(),
}; };
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); const asset = AssetFactory.create({ originalPath: '/path/to/symlink.jpg' });
mocks.asset.getByIds.mockResolvedValue([ mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
{ ...assetStub.noResizePath, id: 'asset-1', originalPath: '/path/to/symlink.jpg' }, mocks.asset.getByIds.mockResolvedValue([asset]);
]);
mocks.storage.realpath.mockResolvedValue('/path/to/realpath.jpg'); mocks.storage.realpath.mockResolvedValue('/path/to/realpath.jpg');
mocks.storage.createZipStream.mockReturnValue(archiveMock); 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, stream: archiveMock.stream,
}); });
expect(archiveMock.addFile).toHaveBeenCalledWith('/path/to/realpath.jpg', 'IMG_123.jpg'); expect(archiveMock.addFile).toHaveBeenCalledWith('/path/to/realpath.jpg', asset.originalFileName);
}); });
}); });

View file

@ -1,6 +1,7 @@
import { AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum'; import { AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
import { DuplicateService } from 'src/services/duplicate.service'; import { DuplicateService } from 'src/services/duplicate.service';
import { SearchService } from 'src/services/search.service'; import { SearchService } from 'src/services/search.service';
import { AssetFactory } from 'test/factories/asset.factory';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { makeStream, newTestService, ServiceMocks } from 'test/utils'; import { makeStream, newTestService, ServiceMocks } from 'test/utils';
@ -38,19 +39,17 @@ describe(SearchService.name, () => {
describe('getDuplicates', () => { describe('getDuplicates', () => {
it('should get duplicates', async () => { it('should get duplicates', async () => {
const asset = AssetFactory.create();
mocks.duplicateRepository.getAll.mockResolvedValue([ mocks.duplicateRepository.getAll.mockResolvedValue([
{ {
duplicateId: 'duplicate-id', duplicateId: 'duplicate-id',
assets: [assetStub.image, assetStub.image], assets: [asset, asset],
}, },
]); ]);
await expect(sut.getDuplicates(authStub.admin)).resolves.toEqual([ await expect(sut.getDuplicates(authStub.admin)).resolves.toEqual([
{ {
duplicateId: 'duplicate-id', duplicateId: 'duplicate-id',
assets: [ assets: [expect.objectContaining({ id: asset.id }), expect.objectContaining({ id: asset.id })],
expect.objectContaining({ id: assetStub.image.id }),
expect.objectContaining({ id: assetStub.image.id }),
],
}, },
]); ]);
}); });
@ -101,7 +100,8 @@ describe(SearchService.name, () => {
}); });
it('should queue missing assets', async () => { 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({}); await sut.handleQueueSearchDuplicates({});
@ -109,13 +109,14 @@ describe(SearchService.name, () => {
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.AssetDetectDuplicates, name: JobName.AssetDetectDuplicates,
data: { id: assetStub.image.id }, data: { id: asset.id },
}, },
]); ]);
}); });
it('should queue all assets', async () => { 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 }); await sut.handleQueueSearchDuplicates({ force: true });
@ -123,7 +124,7 @@ describe(SearchService.name, () => {
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.AssetDetectDuplicates, 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 () => { it('should fail if asset is not found', async () => {
mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue(void 0); 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(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 () => { 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 () => { it('should fail if asset is missing embedding', async () => {
mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, embedding: null }); 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(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 () => { it('should search for duplicates and update asset with duplicateId', async () => {
mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue(hasEmbedding); mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue(hasEmbedding);
mocks.duplicateRepository.search.mockResolvedValue([ const asset = AssetFactory.create();
{ assetId: assetStub.image.id, distance: 0.01, duplicateId: null }, mocks.duplicateRepository.search.mockResolvedValue([{ assetId: asset.id, distance: 0.01, duplicateId: null }]);
]);
mocks.duplicateRepository.merge.mockResolvedValue(); 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 }); const result = await sut.handleSearchDuplicates({ id: hasEmbedding.id });

View file

@ -1,6 +1,7 @@
import { ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum'; import { ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum';
import { JobService } from 'src/services/job.service'; import { JobService } from 'src/services/job.service';
import { JobItem } from 'src/types'; import { JobItem } from 'src/types';
import { AssetFactory } from 'test/factories/asset.factory';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService, ServiceMocks } from 'test/utils';
@ -55,7 +56,7 @@ describe(JobService.name, () => {
{ {
item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1' } }, item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1' } },
jobs: [], jobs: [],
stub: [assetStub.image], stub: [AssetFactory.create({ id: 'asset-id' })],
}, },
{ {
item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1' } }, item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1' } },

View file

@ -6,6 +6,7 @@ import { mapLibrary } from 'src/dtos/library.dto';
import { AssetType, CronJob, ImmichWorker, JobName, JobStatus } from 'src/enum'; import { AssetType, CronJob, ImmichWorker, JobName, JobStatus } from 'src/enum';
import { LibraryService } from 'src/services/library.service'; import { LibraryService } from 'src/services/library.service';
import { ILibraryBulkIdsJob, ILibraryFileJob } from 'src/types'; import { ILibraryBulkIdsJob, ILibraryFileJob } from 'src/types';
import { AssetFactory } from 'test/factories/asset.factory';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub';
@ -548,13 +549,14 @@ describe(LibraryService.name, () => {
it('should import a new asset', async () => { it('should import a new asset', async () => {
const library = factory.library(); const library = factory.library();
const asset = AssetFactory.create();
const mockLibraryJob: ILibraryFileJob = { const mockLibraryJob: ILibraryFileJob = {
libraryId: library.id, libraryId: library.id,
paths: ['/data/user1/photo.jpg'], paths: ['/data/user1/photo.jpg'],
}; };
mocks.asset.createAll.mockResolvedValue([assetStub.image]); mocks.asset.createAll.mockResolvedValue([asset]);
mocks.library.get.mockResolvedValue(library); mocks.library.get.mockResolvedValue(library);
await expect(sut.handleSyncFiles(mockLibraryJob)).resolves.toBe(JobStatus.Success); await expect(sut.handleSyncFiles(mockLibraryJob)).resolves.toBe(JobStatus.Success);
@ -575,7 +577,7 @@ describe(LibraryService.name, () => {
{ {
name: JobName.SidecarCheck, name: JobName.SidecarCheck,
data: { data: {
id: assetStub.image.id, id: asset.id,
source: 'upload', source: 'upload',
}, },
}, },
@ -602,7 +604,7 @@ describe(LibraryService.name, () => {
it('should delete a library', async () => { it('should delete a library', async () => {
const library = factory.library(); const library = factory.library();
mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(AssetFactory.create());
mocks.library.get.mockResolvedValue(library); mocks.library.get.mockResolvedValue(library);
await sut.delete(library.id); await sut.delete(library.id);
@ -614,7 +616,7 @@ describe(LibraryService.name, () => {
it('should allow an external library to be deleted', async () => { it('should allow an external library to be deleted', async () => {
const library = factory.library(); const library = factory.library();
mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(AssetFactory.create());
mocks.library.get.mockResolvedValue(library); mocks.library.get.mockResolvedValue(library);
await sut.delete(library.id); await sut.delete(library.id);
@ -630,7 +632,7 @@ describe(LibraryService.name, () => {
it('should unwatch an external library when deleted', async () => { it('should unwatch an external library when deleted', async () => {
const library = factory.library({ importPaths: ['/foo', '/bar'] }); 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.get.mockResolvedValue(library);
mocks.library.getAll.mockResolvedValue([library]); mocks.library.getAll.mockResolvedValue([library]);
@ -962,7 +964,7 @@ describe(LibraryService.name, () => {
mocks.library.get.mockResolvedValue(library); mocks.library.get.mockResolvedValue(library);
mocks.library.getAll.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' }] })); mocks.storage.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/foo/photo.jpg' }] }));
await sut.watchAll(); await sut.watchAll();
@ -981,7 +983,7 @@ describe(LibraryService.name, () => {
mocks.library.get.mockResolvedValue(library); mocks.library.get.mockResolvedValue(library);
mocks.library.getAll.mockResolvedValue([library]); mocks.library.getAll.mockResolvedValue([library]);
mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(AssetFactory.create());
mocks.storage.watch.mockImplementation( mocks.storage.watch.mockImplementation(
makeMockWatcher({ items: [{ event: 'change', value: '/foo/photo.jpg' }] }), makeMockWatcher({ items: [{ event: 'change', value: '/foo/photo.jpg' }] }),
); );
@ -999,12 +1001,13 @@ describe(LibraryService.name, () => {
it('should handle a file unlink event', async () => { it('should handle a file unlink event', async () => {
const library = factory.library({ importPaths: ['/foo', '/bar'] }); const library = factory.library({ importPaths: ['/foo', '/bar'] });
const asset = AssetFactory.create();
mocks.library.get.mockResolvedValue(library); mocks.library.get.mockResolvedValue(library);
mocks.library.getAll.mockResolvedValue([library]); mocks.library.getAll.mockResolvedValue([library]);
mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(asset);
mocks.storage.watch.mockImplementation( mocks.storage.watch.mockImplementation(
makeMockWatcher({ items: [{ event: 'unlink', value: assetStub.image.originalPath }] }), makeMockWatcher({ items: [{ event: 'unlink', value: asset.originalPath }] }),
); );
await sut.watchAll(); await sut.watchAll();
@ -1013,7 +1016,7 @@ describe(LibraryService.name, () => {
name: JobName.LibraryRemoveAsset, name: JobName.LibraryRemoveAsset,
data: { data: {
libraryId: library.id, libraryId: library.id,
paths: [assetStub.image.originalPath], paths: [asset.originalPath],
}, },
}); });
}); });
@ -1115,7 +1118,7 @@ describe(LibraryService.name, () => {
const library = factory.library(); const library = factory.library();
mocks.library.get.mockResolvedValue(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); await expect(sut.handleDeleteLibrary({ id: library.id })).resolves.toBe(JobStatus.Success);
}); });

View file

@ -1,6 +1,7 @@
import { OutputInfo } from 'sharp'; import { OutputInfo } from 'sharp';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { Exif } from 'src/database'; import { Exif } from 'src/database';
import { AssetEditAction } from 'src/dtos/editing.dto';
import { import {
AssetFileType, AssetFileType,
AssetPathType, AssetPathType,
@ -19,7 +20,7 @@ import {
import { MediaService } from 'src/services/media.service'; import { MediaService } from 'src/services/media.service';
import { JobCounts, RawImageInfo } from 'src/types'; import { JobCounts, RawImageInfo } from 'src/types';
import { AssetFactory } from 'test/factories/asset.factory'; 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 { faceStub } from 'test/fixtures/face.stub';
import { probeStub } from 'test/fixtures/media.stub'; import { probeStub } from 'test/fixtures/media.stub';
import { personStub, personThumbnailStub } from 'test/fixtures/person.stub'; import { personStub, personThumbnailStub } from 'test/fixtures/person.stub';
@ -45,7 +46,8 @@ describe(MediaService.name, () => {
describe('handleQueueGenerateThumbnails', () => { describe('handleQueueGenerateThumbnails', () => {
it('should queue all assets', async () => { 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])); mocks.person.getAll.mockReturnValue(makeStream([personStub.newThumbnail]));
@ -55,7 +57,7 @@ describe(MediaService.name, () => {
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.AssetGenerateThumbnails, 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 () => { 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.getAll.mockReturnValue(makeStream([personStub.noThumbnail, personStub.noThumbnail]));
mocks.person.getRandomFace.mockResolvedValueOnce(faceStub.face1); mocks.person.getRandomFace.mockResolvedValueOnce(faceStub.face1);
@ -120,7 +122,8 @@ describe(MediaService.name, () => {
}); });
it('should queue all assets with missing resize path', async () => { 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()); mocks.person.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: false }); await sut.handleQueueGenerateThumbnails({ force: false });
@ -128,7 +131,7 @@ describe(MediaService.name, () => {
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.AssetGenerateThumbnails, name: JobName.AssetGenerateThumbnails,
data: { id: assetStub.image.id }, data: { id: asset.id },
}, },
]); ]);
@ -264,16 +267,15 @@ describe(MediaService.name, () => {
describe('handleQueueMigration', () => { describe('handleQueueMigration', () => {
it('should remove empty directories and queue jobs', async () => { 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.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts);
mocks.person.getAll.mockReturnValue(makeStream([personStub.withName])); mocks.person.getAll.mockReturnValue(makeStream([personStub.withName]));
await expect(sut.handleQueueMigration()).resolves.toBe(JobStatus.Success); await expect(sut.handleQueueMigration()).resolves.toBe(JobStatus.Success);
expect(mocks.storage.removeEmptyDirs).toHaveBeenCalledTimes(2); expect(mocks.storage.removeEmptyDirs).toHaveBeenCalledTimes(2);
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.AssetFileMigration, data: { id: asset.id } }]);
{ name: JobName.AssetFileMigration, data: { id: assetStub.image.id } },
]);
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ name: JobName.PersonFileMigration, data: { id: personStub.withName.id } }, { name: JobName.PersonFileMigration, data: { id: personStub.withName.id } },
]); ]);
@ -283,39 +285,42 @@ describe(MediaService.name, () => {
describe('handleAssetMigration', () => { describe('handleAssetMigration', () => {
it('should fail if asset does not exist', async () => { it('should fail if asset does not exist', async () => {
mocks.assetJob.getForMigrationJob.mockResolvedValue(void 0); 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(); expect(mocks.move.getByEntity).not.toHaveBeenCalled();
}); });
it('should move asset files', async () => { 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({ mocks.move.create.mockResolvedValue({
entityId: assetStub.image.id, entityId: asset.id,
id: 'move-id', id: 'move-id',
newPath: '/new/path', newPath: '/new/path',
oldPath: '/old/path', oldPath: '/old/path',
pathType: AssetPathType.Original, 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({ expect(mocks.move.create).toHaveBeenCalledWith({
entityId: assetStub.image.id, entityId: asset.id,
pathType: AssetFileType.FullSize, pathType: AssetFileType.FullSize,
oldPath: '/uploads/user-id/fullsize/path.webp', oldPath: asset.files[0].path,
newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id_fullsize.jpeg'), newPath: `/data/thumbs/${asset.ownerId}/${asset.id.slice(0, 2)}/${asset.id.slice(2, 4)}/${asset.id}_fullsize.jpeg`,
}); });
expect(mocks.move.create).toHaveBeenCalledWith({ expect(mocks.move.create).toHaveBeenCalledWith({
entityId: assetStub.image.id, entityId: asset.id,
pathType: AssetFileType.Preview, pathType: AssetFileType.Preview,
oldPath: '/uploads/user-id/thumbs/path.jpg', oldPath: asset.files[1].path,
newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id_preview.jpeg'), newPath: `/data/thumbs/${asset.ownerId}/${asset.id.slice(0, 2)}/${asset.id.slice(2, 4)}/${asset.id}_preview.jpeg`,
}); });
expect(mocks.move.create).toHaveBeenCalledWith({ expect(mocks.move.create).toHaveBeenCalledWith({
entityId: assetStub.image.id, entityId: asset.id,
pathType: AssetFileType.Thumbnail, pathType: AssetFileType.Thumbnail,
oldPath: '/uploads/user-id/webp/path.ext', oldPath: asset.files[2].path,
newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id_thumbnail.webp'), 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); expect(mocks.move.create).toHaveBeenCalledTimes(3);
}); });
@ -339,16 +344,17 @@ describe(MediaService.name, () => {
it('should skip thumbnail generation if asset not found', async () => { it('should skip thumbnail generation if asset not found', async () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(void 0); 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.media.generateThumbnail).not.toHaveBeenCalled();
expect(mocks.asset.update).not.toHaveBeenCalledWith(); expect(mocks.asset.update).not.toHaveBeenCalledWith();
}); });
it('should skip thumbnail generation if asset type is unknown', async () => { 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.probe).not.toHaveBeenCalled();
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
expect(mocks.asset.update).not.toHaveBeenCalledWith(); expect(mocks.asset.update).not.toHaveBeenCalledWith();
@ -372,33 +378,35 @@ describe(MediaService.name, () => {
}); });
it('should delete previous preview if different path', async () => { 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.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({ expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete, name: JobName.FileDelete,
data: { data: {
files: expect.arrayContaining([previewFile.path]), files: expect.arrayContaining([asset.files[0].path]),
}, },
}); });
}); });
it('should generate P3 thumbnails for a wide gamut image', async () => { it('should generate P3 thumbnails for a wide gamut image', async () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ const asset = AssetFactory.from()
...assetStub.image, .exif({ profileDescription: 'Adobe RGB', bitsPerSample: 14 })
exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as Exif, .files([AssetFileType.Preview, AssetFileType.Thumbnail])
}); .build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); 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.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledOnce();
expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, {
colorspace: Colorspace.P3, colorspace: Colorspace.P3,
processInvalidImages: false, processInvalidImages: false,
size: 1440, size: 1440,
@ -444,21 +452,21 @@ describe(MediaService.name, () => {
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
{ {
assetId: 'asset-id', assetId: asset.id,
type: AssetFileType.Preview, type: AssetFileType.Preview,
path: expect.any(String), path: expect.any(String),
isEdited: false, isEdited: false,
isProgressive: false, isProgressive: false,
}, },
{ {
assetId: 'asset-id', assetId: asset.id,
type: AssetFileType.Thumbnail, type: AssetFileType.Thumbnail,
path: expect.any(String), path: expect.any(String),
isEdited: false, isEdited: false,
isProgressive: 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 () => { 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) => { 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.systemMetadata.get.mockResolvedValue({ image: { preview: { format } } });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
const previewPath = `/data/thumbs/user-id/as/se/asset-id_preview.${format}`; const previewPath = `/data/thumbs/${asset.ownerId}/${asset.id.slice(0, 2)}/${asset.id.slice(2, 4)}/${asset.id}_preview.${format}`;
const thumbnailPath = `/data/thumbs/user-id/as/se/asset-id_thumbnail.webp`; 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.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledOnce();
expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, {
colorspace: Colorspace.Srgb, colorspace: Colorspace.Srgb,
processInvalidImages: false, processInvalidImages: false,
size: 1440, size: 1440,
@ -667,18 +676,19 @@ describe(MediaService.name, () => {
}); });
it.each(Object.values(ImageFormat))('should generate an image thumbnail in %s format', async (format) => { 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.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format } } });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
const previewPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id_preview.jpeg`); const previewPath = `/data/thumbs/${asset.ownerId}/${asset.id.slice(0, 2)}/${asset.id.slice(2, 4)}/${asset.id}_preview.jpeg`;
const thumbnailPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id_thumbnail.${format}`); 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.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledOnce();
expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, {
colorspace: Colorspace.Srgb, colorspace: Colorspace.Srgb,
processInvalidImages: false, processInvalidImages: false,
size: 1440, size: 1440,
@ -716,12 +726,13 @@ describe(MediaService.name, () => {
}); });
it('should generate progressive JPEG for preview when enabled', async () => { it('should generate progressive JPEG for preview when enabled', async () => {
const asset = AssetFactory.from().exif().build();
mocks.systemMetadata.get.mockResolvedValue({ mocks.systemMetadata.get.mockResolvedValue({
image: { preview: { progressive: true }, thumbnail: { progressive: false } }, 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( expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer, rawBuffer,
@ -752,12 +763,13 @@ describe(MediaService.name, () => {
}); });
it('should generate progressive JPEG for thumbnail when enabled', async () => { it('should generate progressive JPEG for thumbnail when enabled', async () => {
const asset = AssetFactory.from().exif().build();
mocks.systemMetadata.get.mockResolvedValue({ mocks.systemMetadata.get.mockResolvedValue({
image: { preview: { progressive: false }, thumbnail: { format: ImageFormat.Jpeg, progressive: true } }, 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( expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer, rawBuffer,
@ -809,26 +821,30 @@ describe(MediaService.name, () => {
}); });
it('should delete previous thumbnail if different path', async () => { 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.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({ expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete, name: JobName.FileDelete,
data: { data: {
files: expect.arrayContaining([previewFile.path]), files: expect.arrayContaining([asset.files[0].path]),
}, },
}); });
}); });
it('should extract embedded image if enabled and available', async () => { 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.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); 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).toHaveBeenCalledOnce();
expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedBuffer, { 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 () => { 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.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); mocks.media.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 });
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); 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, colorspace: Colorspace.P3,
processInvalidImages: false, processInvalidImages: false,
size: 1440, size: 1440,
@ -854,13 +873,16 @@ describe(MediaService.name, () => {
}); });
it('should resize original image if embedded image not found', async () => { 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.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).toHaveBeenCalledOnce();
expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, {
colorspace: Colorspace.P3, colorspace: Colorspace.P3,
processInvalidImages: false, processInvalidImages: false,
size: 1440, size: 1440,
@ -868,14 +890,17 @@ describe(MediaService.name, () => {
}); });
it('should resize original image if embedded image extraction is not enabled', async () => { 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.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.extract).not.toHaveBeenCalled();
expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledOnce();
expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, {
colorspace: Colorspace.P3, colorspace: Colorspace.P3,
processInvalidImages: false, processInvalidImages: false,
size: 1440, size: 1440,
@ -884,14 +909,17 @@ describe(MediaService.name, () => {
it('should process invalid images if enabled', async () => { it('should process invalid images if enabled', async () => {
vi.stubEnv('IMMICH_PROCESS_INVALID_IMAGES', 'true'); 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).toHaveBeenCalledOnce();
expect(mocks.media.decodeImage).toHaveBeenCalledWith( expect(mocks.media.decodeImage).toHaveBeenCalledWith(
assetStub.imageDng.originalPath, asset.originalPath,
expect.objectContaining({ processInvalidImages: true }), expect.objectContaining({ processInvalidImages: true }),
); );
@ -917,14 +945,18 @@ describe(MediaService.name, () => {
}); });
it('should extract full-size JPEG preview from RAW', async () => { 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({ mocks.systemMetadata.get.mockResolvedValue({
image: { fullsize: { enabled: true, format: ImageFormat.Webp }, extractEmbedded: true }, image: { fullsize: { enabled: true, format: ImageFormat.Webp }, extractEmbedded: true },
}); });
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); 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).toHaveBeenCalledOnce();
expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedBuffer, { 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 () => { 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({ mocks.systemMetadata.get.mockResolvedValue({
image: { fullsize: { enabled: true, format: ImageFormat.Webp }, extractEmbedded: true }, image: { fullsize: { enabled: true, format: ImageFormat.Webp }, extractEmbedded: true },
}); });
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jxl }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jxl });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); 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).toHaveBeenCalledOnce();
expect(mocks.media.decodeImage).toHaveBeenCalledWith(extractedBuffer, { 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 () => { 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.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true }, extractEmbedded: false } });
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); 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).toHaveBeenCalledOnce();
expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, {
colorspace: Colorspace.P3, colorspace: Colorspace.P3,
processInvalidImages: false, processInvalidImages: false,
}); });
@ -1079,15 +1119,16 @@ describe(MediaService.name, () => {
}); });
it('should skip generating full-size preview for web-friendly images', async () => { 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.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } });
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); 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).toHaveBeenCalledOnce();
expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, {
colorspace: Colorspace.Srgb, colorspace: Colorspace.Srgb,
processInvalidImages: false, processInvalidImages: false,
size: 1440, size: 1440,
@ -1116,7 +1157,7 @@ describe(MediaService.name, () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); 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).toHaveBeenCalledOnce();
expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, {
@ -1161,7 +1202,7 @@ describe(MediaService.name, () => {
.build(); .build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); 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).toHaveBeenCalledOnce();
expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, {
@ -1238,15 +1279,23 @@ describe(MediaService.name, () => {
}); });
it('should upsert 3 edited files for edit jobs', async () => { it('should upsert 3 edited files for edit jobs', async () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ const asset = AssetFactory.from()
...assetStub.withCropEdit, .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'); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
mocks.person.getFaces.mockResolvedValue([]); mocks.person.getFaces.mockResolvedValue([]);
mocks.ocr.getByAssetId.mockResolvedValue([]); mocks.ocr.getByAssetId.mockResolvedValue([]);
await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id }); await sut.handleAssetEditThumbnailGeneration({ id: asset.id });
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith( expect(mocks.asset.upsertFiles).toHaveBeenCalledWith(
expect.arrayContaining([ expect.arrayContaining([
@ -1258,21 +1307,23 @@ describe(MediaService.name, () => {
}); });
it('should apply edits when generating thumbnails', async () => { it('should apply edits when generating thumbnails', async () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ const asset = AssetFactory.from()
...assetStub.withCropEdit, .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.person.getFaces.mockResolvedValue([]);
mocks.ocr.getByAssetId.mockResolvedValue([]); mocks.ocr.getByAssetId.mockResolvedValue([]);
await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id }); await sut.handleAssetEditThumbnailGeneration({ id: asset.id });
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer, rawBuffer,
expect.objectContaining({ expect.objectContaining({
edits: [ edits: [
{ expect.objectContaining({
action: 'crop', action: 'crop',
parameters: { height: 1152, width: 1512, x: 216, y: 1512 }, parameters: { height: 1152, width: 1512, x: 216, y: 1512 },
}, }),
], ],
}), }),
expect.any(String), expect.any(String),
@ -1305,13 +1356,12 @@ describe(MediaService.name, () => {
}); });
it('should generate all 3 edited files if an asset has edits', async () => { it('should generate all 3 edited files if an asset has edits', async () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ const asset = AssetFactory.from().exif().edit().build();
...assetStub.withCropEdit, mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
});
mocks.person.getFaces.mockResolvedValue([]); mocks.person.getFaces.mockResolvedValue([]);
mocks.ocr.getByAssetId.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).toHaveBeenCalledTimes(3);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
@ -1336,21 +1386,20 @@ describe(MediaService.name, () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.media.generateThumbhash.mockResolvedValue(factory.buffer()); 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(); expect(mocks.media.generateThumbhash).toHaveBeenCalled();
}); });
it('should apply thumbhash if job source is edit and edits exist', async () => { it('should apply thumbhash if job source is edit and edits exist', async () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ const asset = AssetFactory.from().exif().edit().build();
...assetStub.withCropEdit, mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
});
const thumbhashBuffer = factory.buffer(); const thumbhashBuffer = factory.buffer();
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
mocks.person.getFaces.mockResolvedValue([]); mocks.person.getFaces.mockResolvedValue([]);
mocks.ocr.getByAssetId.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 })); expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ thumbhash: thumbhashBuffer }));
}); });

View file

@ -125,27 +125,29 @@ describe(MetadataService.name, () => {
describe('handleQueueMetadataExtraction', () => { describe('handleQueueMetadataExtraction', () => {
it('should queue metadata extraction for all assets without exif values', async () => { 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); await expect(sut.handleQueueMetadataExtraction({ force: false })).resolves.toBe(JobStatus.Success);
expect(mocks.assetJob.streamForMetadataExtraction).toHaveBeenCalledWith(false); expect(mocks.assetJob.streamForMetadataExtraction).toHaveBeenCalledWith(false);
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.AssetExtractMetadata, name: JobName.AssetExtractMetadata,
data: { id: assetStub.image.id }, data: { id: asset.id },
}, },
]); ]);
}); });
it('should queue metadata extraction for all assets', async () => { 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); await expect(sut.handleQueueMetadataExtraction({ force: true })).resolves.toBe(JobStatus.Success);
expect(mocks.assetJob.streamForMetadataExtraction).toHaveBeenCalledWith(true); expect(mocks.assetJob.streamForMetadataExtraction).toHaveBeenCalledWith(true);
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.AssetExtractMetadata, 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 () => { it('should handle an asset that could not be found', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(void 0); 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.upsertExif).not.toHaveBeenCalled();
expect(mocks.asset.update).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalled();
}); });
@ -287,8 +289,8 @@ describe(MetadataService.name, () => {
} as Stats); } as Stats);
mockReadTags({ ISO: [160] }); mockReadTags({ ISO: [160] });
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).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 }), { expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 }), {
lockedPropertiesBehavior: 'skip', lockedPropertiesBehavior: 'skip',
}); });
@ -406,7 +408,7 @@ describe(MetadataService.name, () => {
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); 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, { expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, {
userId: asset.ownerId, userId: asset.ownerId,
@ -546,57 +548,59 @@ describe(MetadataService.name, () => {
mockReadTags({ HierarchicalSubject: ['Parent', 2024] }); mockReadTags({ HierarchicalSubject: ['Parent', 2024] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); 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: 'Parent', parent: undefined });
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: '2024', parent: undefined }); expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: '2024', parent: undefined });
}); });
it('should extract ignore / characters in a HierarchicalSubject tag', async () => { 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'] }) }); mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Mom|Dad'] }) });
mockReadTags({ HierarchicalSubject: ['Mom/Dad'] }); mockReadTags({ HierarchicalSubject: ['Mom/Dad'] });
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ expect(mocks.tag.upsertValue).toHaveBeenCalledWith({
userId: 'user-id', userId: asset.ownerId,
value: 'Mom|Dad', value: 'Mom|Dad',
parent: undefined, parent: undefined,
}); });
}); });
it('should ignore HierarchicalSubject when TagsList is present', async () => { it('should ignore HierarchicalSubject when TagsList is present', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); const baseAsset = AssetFactory.from();
mocks.asset.getById.mockResolvedValue({ const asset = baseAsset.build();
...factory.asset(), const updatedAsset = baseAsset.exif({ tags: ['Parent/Child', 'Parent2/Child2'] }).build();
exifInfo: factory.exif({ tags: ['Parent/Child', 'Parent2/Child2'] }), mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
}); mocks.asset.getById.mockResolvedValue(updatedAsset);
mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] }); mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); 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, { expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, {
userId: 'user-id', userId: asset.ownerId,
value: 'Parent', value: 'Parent',
parentId: undefined, parentId: undefined,
}); });
expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, {
userId: 'user-id', userId: asset.ownerId,
value: 'Parent/Child', value: 'Parent/Child',
parentId: 'tag-parent', parentId: 'tag-parent',
}); });
}); });
it('should remove existing tags', async () => { it('should remove existing tags', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mockReadTags({}); 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 () => { 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 () => { it('should handle an invalid Directory Item', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mockReadTags({ mockReadTags({
MotionPhoto: 1, MotionPhoto: 1,
ContainerDirectory: [{ Foo: 100 }], ContainerDirectory: [{ Foo: 100 }],
}); });
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: asset.id });
}); });
it('should extract the correct video orientation', async () => { it('should extract the correct video orientation', async () => {
@ -915,6 +920,7 @@ describe(MetadataService.name, () => {
it('should save all metadata', async () => { it('should save all metadata', async () => {
const dateForTest = new Date('1970-01-01T00:00:00.000-11:30'); const dateForTest = new Date('1970-01-01T00:00:00.000-11:30');
const asset = AssetFactory.create();
const tags: ImmichTags = { const tags: ImmichTags = {
BitsPerSample: 1, BitsPerSample: 1,
@ -941,14 +947,14 @@ describe(MetadataService.name, () => {
Rating: 3, Rating: 3,
}; };
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mockReadTags(tags); mockReadTags(tags);
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).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
{ {
assetId: assetStub.image.id, assetId: asset.id,
bitsPerSample: expect.any(Number), bitsPerSample: expect.any(Number),
autoStackId: null, autoStackId: null,
colorspace: tags.ColorSpace, colorspace: tags.ColorSpace,
@ -983,7 +989,7 @@ describe(MetadataService.name, () => {
); );
expect(mocks.asset.update).toHaveBeenCalledWith( expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
id: assetStub.image.id, id: asset.id,
duration: null, duration: null,
fileCreatedAt: dateForTest, fileCreatedAt: dateForTest,
localDateTime: DateTime.fromISO('1970-01-01T00:00:00.000Z').toJSDate(), 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 // https://github.com/photostructure/exiftool-vendored.js/issues/203
// this only tests our assumptions of exiftool-vendored, demonstrating the issue // this only tests our assumptions of exiftool-vendored, demonstrating the issue
const asset = AssetFactory.create();
const someDate = '2024-09-01T00:00:00.000'; const someDate = '2024-09-01T00:00:00.000';
expect(ExifDateTime.fromISO(someDate + 'Z')?.zone).toBe('UTC'); 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 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'), DateTimeOriginal: ExifDateTime.fromISO(someDate + '+00:00'),
tz: undefined, tz: undefined,
}; };
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mockReadTags(tags); mockReadTags(tags);
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).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
timeZone: 'UTC+0', timeZone: 'UTC+0',
@ -1034,14 +1041,15 @@ describe(MetadataService.name, () => {
expect(mocks.asset.upsertExif).toHaveBeenCalled(); expect(mocks.asset.upsertExif).toHaveBeenCalled();
expect(mocks.asset.update).toHaveBeenCalledWith( expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
id: assetStub.image.id, id: assetStub.video.id,
duration: '00:00:06.210', duration: '00:00:06.210',
}), }),
); );
}); });
it('should only extract duration for videos', async () => { 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({ mocks.media.probe.mockResolvedValue({
...probeStub.videoStreamH264, ...probeStub.videoStreamH264,
format: { format: {
@ -1049,13 +1057,13 @@ describe(MetadataService.name, () => {
duration: 6.21, 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.upsertExif).toHaveBeenCalled();
expect(mocks.asset.update).toHaveBeenCalledWith( expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
id: assetStub.image.id, id: asset.id,
duration: null, duration: null,
}), }),
); );
@ -1077,7 +1085,7 @@ describe(MetadataService.name, () => {
expect(mocks.asset.upsertExif).toHaveBeenCalled(); expect(mocks.asset.upsertExif).toHaveBeenCalled();
expect(mocks.asset.update).toHaveBeenCalledWith( expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
id: assetStub.image.id, id: assetStub.video.id,
duration: null, duration: null,
}), }),
); );
@ -1106,45 +1114,34 @@ describe(MetadataService.name, () => {
}); });
it('should use Duration from exif', async () => { it('should use Duration from exif', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ const asset = AssetFactory.create({ originalFileName: 'file.webp' });
...assetStub.image, mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
originalPath: '/original/path.webp',
});
mockReadTags({ Duration: 123 }, {}); mockReadTags({ Duration: 123 }, {});
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.metadata.readTags).toHaveBeenCalledTimes(1); expect(mocks.metadata.readTags).toHaveBeenCalledTimes(1);
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: '00:02:03.000' })); expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: '00:02:03.000' }));
}); });
it('should prefer Duration from exif over sidecar', async () => { it('should prefer Duration from exif over sidecar', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ const asset = AssetFactory.from({ originalFileName: 'file.webp' }).file({ type: AssetFileType.Sidecar }).build();
...assetStub.image, mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
originalPath: '/original/path.webp',
files: [
{
id: 'some-id',
type: AssetFileType.Sidecar,
path: '/path/to/something',
isEdited: false,
},
],
});
mockReadTags({ Duration: 123 }, { Duration: 456 }); 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.metadata.readTags).toHaveBeenCalledTimes(2);
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: '00:02:03.000' })); expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: '00:02:03.000' }));
}); });
it('should ignore all Duration tags for definitely static images', async () => { 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 }); 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.metadata.readTags).toHaveBeenCalledTimes(1);
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: null })); expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: null }));
@ -1168,10 +1165,11 @@ describe(MetadataService.name, () => {
}); });
it('should trim whitespace from description', async () => { 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' }); 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(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
description: '', description: '',
@ -1180,7 +1178,7 @@ describe(MetadataService.name, () => {
); );
mockReadTags({ ImageDescription: ' my\n description' }); mockReadTags({ ImageDescription: ' my\n description' });
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
description: 'my\n description', description: 'my\n description',
@ -1190,10 +1188,11 @@ describe(MetadataService.name, () => {
}); });
it('should handle a numeric description', async () => { 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 }); mockReadTags({ Description: 1000 });
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
description: '1000', description: '1000',
@ -1203,40 +1202,44 @@ describe(MetadataService.name, () => {
}); });
it('should skip importing metadata when the feature is disabled', async () => { 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 } } }); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: false } } });
mockReadTags(makeFaceTags({ Name: 'Person 1' })); mockReadTags(makeFaceTags({ Name: 'Person 1' }));
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.person.getDistinctNames).not.toHaveBeenCalled(); expect(mocks.person.getDistinctNames).not.toHaveBeenCalled();
}); });
it('should skip importing metadata face for assets without tags.RegionInfo', async () => { 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 } } }); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags(); mockReadTags();
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.person.getDistinctNames).not.toHaveBeenCalled(); expect(mocks.person.getDistinctNames).not.toHaveBeenCalled();
}); });
it('should skip importing faces without name', async () => { 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 } } }); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags(makeFaceTags()); mockReadTags(makeFaceTags());
mocks.person.getDistinctNames.mockResolvedValue([]); mocks.person.getDistinctNames.mockResolvedValue([]);
mocks.person.createAll.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.createAll).not.toHaveBeenCalled();
expect(mocks.person.refreshFaces).not.toHaveBeenCalled(); expect(mocks.person.refreshFaces).not.toHaveBeenCalled();
expect(mocks.person.updateAll).not.toHaveBeenCalled(); expect(mocks.person.updateAll).not.toHaveBeenCalled();
}); });
it('should skip importing faces with empty name', async () => { 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 } } }); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags(makeFaceTags({ Name: '' })); mockReadTags(makeFaceTags({ Name: '' }));
mocks.person.getDistinctNames.mockResolvedValue([]); mocks.person.getDistinctNames.mockResolvedValue([]);
mocks.person.createAll.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.createAll).not.toHaveBeenCalled();
expect(mocks.person.refreshFaces).not.toHaveBeenCalled(); expect(mocks.person.refreshFaces).not.toHaveBeenCalled();
expect(mocks.person.updateAll).not.toHaveBeenCalled(); expect(mocks.person.updateAll).not.toHaveBeenCalled();
@ -1414,10 +1417,11 @@ describe(MetadataService.name, () => {
}); });
it('should handle invalid modify date', async () => { 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' }); 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(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
modifyDate: expect.any(Date), modifyDate: expect.any(Date),
@ -1427,10 +1431,11 @@ describe(MetadataService.name, () => {
}); });
it('should handle invalid rating value', async () => { 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 }); mockReadTags({ Rating: 6 });
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
rating: null, rating: null,
@ -1440,10 +1445,11 @@ describe(MetadataService.name, () => {
}); });
it('should handle valid rating value', async () => { 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 }); mockReadTags({ Rating: 5 });
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
rating: 5, rating: 5,
@ -1453,10 +1459,11 @@ describe(MetadataService.name, () => {
}); });
it('should handle valid negative rating value', async () => { 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 }); mockReadTags({ Rating: -1 });
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
rating: -1, rating: -1,
@ -1466,11 +1473,12 @@ describe(MetadataService.name, () => {
}); });
it('should handle livePhotoCID not set', async () => { 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.findLivePhotoMatch).not.toHaveBeenCalled();
expect(mocks.asset.update).not.toHaveBeenCalledWith( expect(mocks.asset.update).not.toHaveBeenCalledWith(
expect.objectContaining({ visibility: AssetVisibility.Hidden }), expect.objectContaining({ visibility: AssetVisibility.Hidden }),
@ -1579,10 +1587,11 @@ describe(MetadataService.name, () => {
}, },
{ exif: { AndroidMake: '1', AndroidModel: '2' }, expected: { make: '1', model: '2' } }, { exif: { AndroidMake: '1', AndroidModel: '2' }, expected: { make: '1', model: '2' } },
])('should read camera make and model $exif -> $expected', async ({ exif, expected }) => { ])('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); mockReadTags(exif);
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining(expected), { expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining(expected), {
lockedPropertiesBehavior: 'skip', lockedPropertiesBehavior: 'skip',
}); });
@ -1603,10 +1612,11 @@ describe(MetadataService.name, () => {
{ exif: { LensID: ' Unknown 6-30mm' }, expected: null }, { exif: { LensID: ' Unknown 6-30mm' }, expected: null },
{ exif: { LensID: '' }, expected: null }, { exif: { LensID: '' }, expected: null },
])('should read camera lens information $exif -> $expected', async ({ exif, expected }) => { ])('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); mockReadTags(exif);
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
lensModel: expected, lensModel: expected,
@ -1616,10 +1626,11 @@ describe(MetadataService.name, () => {
}); });
it('should properly set width/height for normal images', async () => { 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 }); mockReadTags({ ImageWidth: 1000, ImageHeight: 2000 });
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.asset.update).toHaveBeenCalledWith( expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
width: 1000, width: 1000,
@ -1629,10 +1640,11 @@ describe(MetadataService.name, () => {
}); });
it('should properly swap asset width/height for rotated images', async () => { 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 }); 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(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
width: 2000, width: 2000,
@ -1642,14 +1654,11 @@ describe(MetadataService.name, () => {
}); });
it('should not overwrite existing width/height if they already exist', async () => { it('should not overwrite existing width/height if they already exist', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ const asset = AssetFactory.create({ width: 1920, height: 1080 });
...assetStub.image, mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
width: 1920,
height: 1080,
});
mockReadTags({ ImageWidth: 1280, ImageHeight: 720 }); 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(mocks.asset.update).not.toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
width: 1280, width: 1280,
@ -1685,7 +1694,7 @@ describe(MetadataService.name, () => {
it('should do nothing if asset could not be found', async () => { it('should do nothing if asset could not be found', async () => {
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(void 0); 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(); expect(mocks.asset.update).not.toHaveBeenCalled();
}); });

View file

@ -6,6 +6,7 @@ import { NotificationService } from 'src/services/notification.service';
import { INotifyAlbumUpdateJob } from 'src/types'; import { INotifyAlbumUpdateJob } from 'src/types';
import { AlbumFactory } from 'test/factories/album.factory'; import { AlbumFactory } from 'test/factories/album.factory';
import { AssetFileFactory } from 'test/factories/asset-file.factory'; import { AssetFileFactory } from 'test/factories/asset-file.factory';
import { AssetFactory } from 'test/factories/asset.factory';
import { UserFactory } from 'test/factories/user.factory'; import { UserFactory } from 'test/factories/user.factory';
import { notificationStub } from 'test/fixtures/notification.stub'; import { notificationStub } from 'test/fixtures/notification.stub';
import { userStub } from 'test/fixtures/user.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 () => { it('should send invite email with album thumbnail and arbitrary extension', async () => {
const assetFile = AssetFileFactory.create({ path: 'some-thumb.ext', type: AssetFileType.Thumbnail }); const asset = AssetFactory.from().file({ type: AssetFileType.Thumbnail }).build();
const album = AlbumFactory.create({ albumThumbnailAssetId: assetFile.assetId }); const album = AlbumFactory.from({ albumThumbnailAssetId: asset.id }).asset(asset).build();
mocks.album.getById.mockResolvedValue(album); mocks.album.getById.mockResolvedValue(album);
mocks.user.get.mockResolvedValue({ mocks.user.get.mockResolvedValue({
...userStub.user1, ...userStub.user1,
@ -407,7 +408,7 @@ describe(NotificationService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({ server: {} }); mocks.systemMetadata.get.mockResolvedValue({ server: {} });
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent); mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); 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); await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success);
expect(mocks.assetJob.getAlbumThumbnailFiles).toHaveBeenCalledWith( expect(mocks.assetJob.getAlbumThumbnailFiles).toHaveBeenCalledWith(
@ -418,7 +419,7 @@ describe(NotificationService.name, () => {
name: JobName.SendMail, name: JobName.SendMail,
data: expect.objectContaining({ data: expect.objectContaining({
subject: expect.stringContaining('You have been added to a shared album'), 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() }],
}), }),
}); });
}); });

View file

@ -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 { 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 { systemConfigStub } from 'test/fixtures/system-config.stub';
import { makeStream, newTestService, ServiceMocks } from 'test/utils'; import { makeStream, newTestService, ServiceMocks } from 'test/utils';
@ -14,7 +14,7 @@ describe(OcrService.name, () => {
mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices); mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices);
mocks.assetJob.getForOcr.mockResolvedValue({ mocks.assetJob.getForOcr.mockResolvedValue({
visibility: AssetVisibility.Timeline, 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 () => { 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 }); 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); expect(mocks.assetJob.streamForOcrJob).toHaveBeenCalledWith(false);
}); });
it('should queue all the assets', async () => { 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 }); 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); expect(mocks.assetJob.streamForOcrJob).toHaveBeenCalledWith(true);
}); });
}); });
@ -70,15 +72,17 @@ describe(OcrService.name, () => {
}); });
it('should skip assets without a resize path', async () => { it('should skip assets without a resize path', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForOcr.mockResolvedValue({ visibility: AssetVisibility.Timeline, previewFile: null }); 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.ocr.upsert).not.toHaveBeenCalled();
expect(mocks.machineLearning.ocr).not.toHaveBeenCalled(); expect(mocks.machineLearning.ocr).not.toHaveBeenCalled();
}); });
it('should save the returned objects', async () => { it('should save the returned objects', async () => {
const asset = AssetFactory.create();
mocks.machineLearning.ocr.mockResolvedValue({ mocks.machineLearning.ocr.mockResolvedValue({
box: [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160], box: [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160],
boxScore: [0.9, 0.8], boxScore: [0.9, 0.8],
@ -86,7 +90,7 @@ describe(OcrService.name, () => {
textScore: [0.95, 0.85], 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( expect(mocks.machineLearning.ocr).toHaveBeenCalledWith(
'/uploads/user-id/thumbs/path.jpg', '/uploads/user-id/thumbs/path.jpg',
@ -98,10 +102,10 @@ describe(OcrService.name, () => {
}), }),
); );
expect(mocks.ocr.upsert).toHaveBeenCalledWith( expect(mocks.ocr.upsert).toHaveBeenCalledWith(
assetStub.image.id, asset.id,
[ [
{ {
assetId: assetStub.image.id, assetId: asset.id,
boxScore: 0.9, boxScore: 0.9,
text: 'One Two Three', text: 'One Two Three',
textScore: 0.95, textScore: 0.95,
@ -115,7 +119,7 @@ describe(OcrService.name, () => {
y4: 80, y4: 80,
}, },
{ {
assetId: assetStub.image.id, assetId: asset.id,
boxScore: 0.8, boxScore: 0.8,
text: 'Four Five', text: 'Four Five',
textScore: 0.85, textScore: 0.85,
@ -134,6 +138,7 @@ describe(OcrService.name, () => {
}); });
it('should apply config settings', async () => { it('should apply config settings', async () => {
const asset = AssetFactory.create();
mocks.systemMetadata.get.mockResolvedValue({ mocks.systemMetadata.get.mockResolvedValue({
machineLearning: { machineLearning: {
enabled: true, enabled: true,
@ -148,7 +153,7 @@ describe(OcrService.name, () => {
}); });
mockOcrResult(); 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( expect(mocks.machineLearning.ocr).toHaveBeenCalledWith(
'/uploads/user-id/thumbs/path.jpg', '/uploads/user-id/thumbs/path.jpg',
@ -159,16 +164,17 @@ describe(OcrService.name, () => {
maxResolution: 1500, maxResolution: 1500,
}), }),
); );
expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, [], ''); expect(mocks.ocr.upsert).toHaveBeenCalledWith(asset.id, [], '');
}); });
it('should skip invisible assets', async () => { it('should skip invisible assets', async () => {
const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build();
mocks.assetJob.getForOcr.mockResolvedValue({ mocks.assetJob.getForOcr.mockResolvedValue({
visibility: AssetVisibility.Hidden, 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.machineLearning.ocr).not.toHaveBeenCalled();
expect(mocks.ocr.upsert).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 () => { it('should fail if asset could not be found', async () => {
mocks.assetJob.getForOcr.mockResolvedValue(void 0); 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.machineLearning.ocr).not.toHaveBeenCalled();
expect(mocks.ocr.upsert).not.toHaveBeenCalled(); expect(mocks.ocr.upsert).not.toHaveBeenCalled();
@ -185,79 +191,84 @@ describe(OcrService.name, () => {
describe('search tokenization', () => { describe('search tokenization', () => {
it('should generate bigrams for Chinese text', async () => { it('should generate bigrams for Chinese text', async () => {
const asset = AssetFactory.create();
mockOcrResult('機器學習'); 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 () => { it('should generate bigrams for Japanese text', async () => {
const asset = AssetFactory.create();
mockOcrResult('テスト'); 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 () => { it('should generate bigrams for Korean text', async () => {
const asset = AssetFactory.create();
mockOcrResult('한국어'); 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 () => { it('should pass through Latin text unchanged', async () => {
const asset = AssetFactory.create();
mockOcrResult('Hello World'); 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 () => { it('should handle mixed CJK and Latin text', async () => {
const asset = AssetFactory.create();
mockOcrResult('機器學習Model'); 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 () => { it('should handle year followed by CJK', async () => {
const asset = AssetFactory.create();
mockOcrResult('2024年レポート'); mockOcrResult('2024年レポート');
await sut.handleOcr({ id: assetStub.image.id }); await sut.handleOcr({ id: asset.id });
expect(mocks.ocr.upsert).toHaveBeenCalledWith( expect(mocks.ocr.upsert).toHaveBeenCalledWith(asset.id, expect.any(Array), '2024 年レ レポ ポー ート');
assetStub.image.id,
expect.any(Array),
'2024 年レ レポ ポー ート',
);
}); });
it('should join multiple OCR boxes', async () => { it('should join multiple OCR boxes', async () => {
const asset = AssetFactory.create();
mockOcrResult('機器', 'Learning'); 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 () => { it('should normalize whitespace', async () => {
const asset = AssetFactory.create();
mockOcrResult(' Hello World '); 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 () => { it('should keep single CJK characters', async () => {
const asset = AssetFactory.create();
mockOcrResult('A', '中', 'B'); 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');
}); });
}); });
}); });

View file

@ -1,12 +1,12 @@
import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BadRequestException, NotFoundException } from '@nestjs/common';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.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 { DetectedFaces } from 'src/repositories/machine-learning.repository';
import { FaceSearchResult } from 'src/repositories/search.repository'; import { FaceSearchResult } from 'src/repositories/search.repository';
import { PersonService } from 'src/services/person.service'; import { PersonService } from 'src/services/person.service';
import { ImmichFileResponse } from 'src/utils/file'; 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 { authStub } from 'test/fixtures/auth.stub';
import { faceStub } from 'test/fixtures/face.stub'; import { faceStub } from 'test/fixtures/face.stub';
import { personStub } from 'test/fixtures/person.stub'; import { personStub } from 'test/fixtures/person.stub';
@ -261,7 +261,7 @@ describe(PersonService.name, () => {
it("should update a person's thumbnailPath", async () => { it("should update a person's thumbnailPath", async () => {
mocks.person.update.mockResolvedValue(personStub.withName); mocks.person.update.mockResolvedValue(personStub.withName);
mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]); 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'])); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect( await expect(
@ -331,7 +331,7 @@ describe(PersonService.name, () => {
await expect( await expect(
sut.reassignFaces(authStub.admin, personStub.noName.id, { 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(); ).resolves.toBeDefined();
@ -352,9 +352,10 @@ describe(PersonService.name, () => {
describe('getFacesById', () => { describe('getFacesById', () => {
it('should get the bounding boxes for an asset', async () => { 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.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([ await expect(sut.getFacesById(authStub.admin, { id: faceStub.face1.assetId })).resolves.toStrictEqual([
mapFaces(faceStub.primaryFace1, authStub.admin), mapFaces(faceStub.primaryFace1, authStub.admin),
]); ]);
@ -455,7 +456,8 @@ describe(PersonService.name, () => {
}); });
it('should queue missing assets', async () => { 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 }); await sut.handleQueueDetectFaces({ force: false });
@ -464,13 +466,14 @@ describe(PersonService.name, () => {
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.AssetDetectFaces, name: JobName.AssetDetectFaces,
data: { id: assetStub.image.id }, data: { id: asset.id },
}, },
]); ]);
}); });
it('should queue all assets', async () => { 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]); mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.withName]);
await sut.handleQueueDetectFaces({ force: true }); await sut.handleQueueDetectFaces({ force: true });
@ -483,13 +486,14 @@ describe(PersonService.name, () => {
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.AssetDetectFaces, name: JobName.AssetDetectFaces,
data: { id: assetStub.image.id }, data: { id: asset.id },
}, },
]); ]);
}); });
it('should refresh all assets', async () => { 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 }); await sut.handleQueueDetectFaces({ force: undefined });
@ -501,16 +505,17 @@ describe(PersonService.name, () => {
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.AssetDetectFaces, name: JobName.AssetDetectFaces,
data: { id: assetStub.image.id }, data: { id: asset.id },
}, },
]); ]);
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.PersonCleanup }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.PersonCleanup });
}); });
it('should delete existing people and faces if forced', async () => { 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.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson]));
mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); 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.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]);
mocks.person.deleteFaces.mockResolvedValue(); mocks.person.deleteFaces.mockResolvedValue();
@ -520,7 +525,7 @@ describe(PersonService.name, () => {
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.AssetDetectFaces, name: JobName.AssetDetectFaces,
data: { id: assetStub.image.id }, data: { id: asset.id },
}, },
]); ]);
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]); expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]);
@ -718,26 +723,28 @@ describe(PersonService.name, () => {
}); });
it('should skip when no resize path', async () => { it('should skip when no resize path', async () => {
mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ ...assetStub.noResizePath, files: [] }); const asset = AssetFactory.create();
await sut.handleDetectFaces({ id: assetStub.noResizePath.id }); mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset);
await sut.handleDetectFaces({ id: asset.id });
expect(mocks.machineLearning.detectFaces).not.toHaveBeenCalled(); expect(mocks.machineLearning.detectFaces).not.toHaveBeenCalled();
}); });
it('should handle no results', async () => { it('should handle no results', async () => {
const start = Date.now(); const start = Date.now();
const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build();
mocks.machineLearning.detectFaces.mockResolvedValue({ imageHeight: 500, imageWidth: 400, faces: [] }); mocks.machineLearning.detectFaces.mockResolvedValue({ imageHeight: 500, imageWidth: 400, faces: [] });
mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ ...assetStub.image, 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.machineLearning.detectFaces).toHaveBeenCalledWith( expect(mocks.machineLearning.detectFaces).toHaveBeenCalledWith(
'/uploads/user-id/thumbs/path.jpg', asset.files[0].path,
expect.objectContaining({ minScore: 0.7, modelName: 'buffalo_l' }), expect.objectContaining({ minScore: 0.7, modelName: 'buffalo_l' }),
); );
expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.job.queue).not.toHaveBeenCalled();
expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(mocks.job.queueAll).not.toHaveBeenCalled();
expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith({ expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith({
assetId: assetStub.image.id, assetId: asset.id,
facesRecognizedAt: expect.any(Date), facesRecognizedAt: expect.any(Date),
}); });
const facesRecognizedAt = mocks.asset.upsertJobStatus.mock.calls[0][0].facesRecognizedAt as 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 () => { 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.machineLearning.detectFaces.mockResolvedValue(detectFaceMock);
mocks.search.searchFaces.mockResolvedValue([{ ...faceStub.face1, distance: 0.7 }]); 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(); 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([ expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ name: JobName.FacialRecognitionQueueAll, data: { force: false } }, { name: JobName.FacialRecognitionQueueAll, data: { force: false } },
{ name: JobName.FacialRecognition, data: { id: faceId } }, { 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 () => { 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.machineLearning.detectFaces.mockResolvedValue({ faces: [], imageHeight: 500, imageWidth: 400 });
mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset);
...assetStub.image,
faces: [faceStub.primaryFace1],
files: [assetStub.image.files[1]],
});
await sut.handleDetectFaces({ id: assetStub.image.id }); await sut.handleDetectFaces({ id: asset.id });
expect(mocks.person.refreshFaces).toHaveBeenCalledWith([], [faceStub.primaryFace1.id], []); expect(mocks.person.refreshFaces).toHaveBeenCalledWith([], [faceStub.primaryFace1.id], []);
expect(mocks.job.queueAll).not.toHaveBeenCalled(); 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 () => { 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.machineLearning.detectFaces.mockResolvedValue(detectFaceMock);
mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset);
...assetStub.image,
faces: [faceStub.primaryFace1],
files: [assetStub.image.files[1]],
});
mocks.person.refreshFaces.mockResolvedValue(); 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([ expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ name: JobName.FacialRecognitionQueueAll, data: { force: false } }, { name: JobName.FacialRecognitionQueueAll, data: { force: false } },
{ name: JobName.FacialRecognition, data: { id: faceId } }, { name: JobName.FacialRecognition, data: { id: faceId } },
@ -798,15 +804,12 @@ describe(PersonService.name, () => {
}); });
it('should add embedding to matching metadata face', async () => { 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.machineLearning.detectFaces.mockResolvedValue(detectFaceMock);
mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset);
...assetStub.image,
faces: [faceStub.fromExif1],
files: [assetStub.image.files[1]],
});
mocks.person.refreshFaces.mockResolvedValue(); mocks.person.refreshFaces.mockResolvedValue();
await sut.handleDetectFaces({ id: assetStub.image.id }); await sut.handleDetectFaces({ id: asset.id });
expect(mocks.person.refreshFaces).toHaveBeenCalledWith( expect(mocks.person.refreshFaces).toHaveBeenCalledWith(
[], [],
@ -819,16 +822,13 @@ describe(PersonService.name, () => {
}); });
it('should not add embedding to non-matching metadata face', async () => { 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.machineLearning.detectFaces.mockResolvedValue(detectFaceMock);
mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset);
...assetStub.image,
faces: [faceStub.fromExif2],
files: [assetStub.image.files[1]],
});
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([ expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ name: JobName.FacialRecognitionQueueAll, data: { force: false } }, { name: JobName.FacialRecognitionQueueAll, data: { force: false } },
{ name: JobName.FacialRecognition, data: { id: faceId } }, { name: JobName.FacialRecognition, data: { id: faceId } },

View file

@ -1,10 +1,10 @@
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import _ from 'lodash';
import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { SharedLinkType } from 'src/enum'; import { SharedLinkType } from 'src/enum';
import { SharedLinkService } from 'src/services/shared-link.service'; import { SharedLinkService } from 'src/services/shared-link.service';
import { AlbumFactory } from 'test/factories/album.factory'; 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 { authStub } from 'test/fixtures/auth.stub';
import { sharedLinkResponseStub, sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { sharedLinkResponseStub, sharedLinkStub } from 'test/fixtures/shared-link.stub';
import { factory } from 'test/small.factory'; import { factory } from 'test/small.factory';
@ -142,12 +142,13 @@ describe(SharedLinkService.name, () => {
}); });
it('should create an individual shared link', async () => { 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); mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual);
await sut.create(authStub.admin, { await sut.create(authStub.admin, {
type: SharedLinkType.Individual, type: SharedLinkType.Individual,
assetIds: [assetStub.image.id], assetIds: [asset.id],
showMetadata: true, showMetadata: true,
allowDownload: true, allowDownload: true,
allowUpload: true, allowUpload: true,
@ -155,7 +156,7 @@ describe(SharedLinkService.name, () => {
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id, authStub.admin.user.id,
new Set([assetStub.image.id]), new Set([asset.id]),
false, false,
); );
expect(mocks.sharedLink.create).toHaveBeenCalledWith({ expect(mocks.sharedLink.create).toHaveBeenCalledWith({
@ -165,7 +166,7 @@ describe(SharedLinkService.name, () => {
allowDownload: true, allowDownload: true,
slug: null, slug: null,
allowUpload: true, allowUpload: true,
assetIds: [assetStub.image.id], assetIds: [asset.id],
description: null, description: null,
expiresAt: null, expiresAt: null,
showExif: true, 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 () => { 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); mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual);
await sut.create(authStub.admin, { await sut.create(authStub.admin, {
type: SharedLinkType.Individual, type: SharedLinkType.Individual,
assetIds: [assetStub.image.id], assetIds: [asset.id],
showMetadata: false, showMetadata: false,
allowDownload: true, allowDownload: true,
allowUpload: true, allowUpload: true,
@ -187,7 +189,7 @@ describe(SharedLinkService.name, () => {
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id, authStub.admin.user.id,
new Set([assetStub.image.id]), new Set([asset.id]),
false, false,
); );
expect(mocks.sharedLink.create).toHaveBeenCalledWith({ expect(mocks.sharedLink.create).toHaveBeenCalledWith({
@ -196,7 +198,7 @@ describe(SharedLinkService.name, () => {
albumId: null, albumId: null,
allowDownload: false, allowDownload: false,
allowUpload: true, allowUpload: true,
assetIds: [assetStub.image.id], assetIds: [asset.id],
description: null, description: null,
expiresAt: null, expiresAt: null,
showExif: false, showExif: false,
@ -263,25 +265,28 @@ describe(SharedLinkService.name, () => {
}); });
it('should add assets to a shared link', async () => { it('should add assets to a shared link', async () => {
mocks.sharedLink.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); const asset = AssetFactory.create();
mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); const sharedLink = SharedLinkFactory.from().asset(asset).build();
mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.individual); const newAsset = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-3'])); 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( 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([ ).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-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.access.asset.checkOwnerAccess).toHaveBeenCalledTimes(1);
expect(mocks.sharedLink.update).toHaveBeenCalled(); expect(mocks.sharedLink.update).toHaveBeenCalled();
expect(mocks.sharedLink.update).toHaveBeenCalledWith({ expect(mocks.sharedLink.update).toHaveBeenCalledWith({
...sharedLinkStub.individual, ...sharedLink,
slug: null, slug: null,
assetIds: ['asset-3'], assetIds: [newAsset.id],
}); });
}); });
}); });
@ -296,20 +301,22 @@ describe(SharedLinkService.name, () => {
}); });
it('should remove assets from a shared link', async () => { it('should remove assets from a shared link', async () => {
mocks.sharedLink.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); const asset = AssetFactory.create();
mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); const sharedLink = SharedLinkFactory.from().asset(asset).build();
mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.individual); mocks.sharedLink.get.mockResolvedValue(sharedLink);
mocks.sharedLinkAsset.remove.mockResolvedValue([assetStub.image.id]); mocks.sharedLink.create.mockResolvedValue(sharedLink);
mocks.sharedLink.update.mockResolvedValue(sharedLink);
mocks.sharedLinkAsset.remove.mockResolvedValue([asset.id]);
await expect( 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([ ).resolves.toEqual([
{ assetId: assetStub.image.id, success: true }, { assetId: asset.id, success: true },
{ assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND }, { assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND },
]); ]);
expect(mocks.sharedLinkAsset.remove).toHaveBeenCalledWith('link-1', [assetStub.image.id, 'asset-2']); expect(mocks.sharedLinkAsset.remove).toHaveBeenCalledWith(sharedLink.id, [asset.id, 'asset-2']);
expect(mocks.sharedLink.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [] }); expect(mocks.sharedLink.update).toHaveBeenCalledWith(expect.objectContaining({ assets: [] }));
}); });
}); });
@ -333,7 +340,7 @@ describe(SharedLinkService.name, () => {
await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({ await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({
description: '1 shared photos & videos', 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', title: 'Public Share',
}); });

View file

@ -1,8 +1,8 @@
import { SystemConfig } from 'src/config'; 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 { SmartInfoService } from 'src/services/smart-info.service';
import { getCLIPModelInfo } from 'src/utils/misc'; 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 { systemConfigStub } from 'test/fixtures/system-config.stub';
import { makeStream, newTestService, ServiceMocks } from 'test/utils'; import { makeStream, newTestService, ServiceMocks } from 'test/utils';
@ -13,7 +13,7 @@ describe(SmartInfoService.name, () => {
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(SmartInfoService)); ({ sut, mocks } = newTestService(SmartInfoService));
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getByIds.mockResolvedValue([AssetFactory.create()]);
mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices); mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices);
}); });
@ -155,25 +155,23 @@ describe(SmartInfoService.name, () => {
}); });
it('should queue the assets without clip embeddings', async () => { 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 }); await sut.handleQueueEncodeClip({ force: false });
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SmartSearch, data: { id: asset.id } }]);
{ name: JobName.SmartSearch, data: { id: assetStub.image.id } },
]);
expect(mocks.assetJob.streamForEncodeClip).toHaveBeenCalledWith(false); expect(mocks.assetJob.streamForEncodeClip).toHaveBeenCalledWith(false);
expect(mocks.database.setDimensionSize).not.toHaveBeenCalled(); expect(mocks.database.setDimensionSize).not.toHaveBeenCalled();
}); });
it('should queue all the assets', async () => { 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 }); await sut.handleQueueEncodeClip({ force: true });
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SmartSearch, data: { id: asset.id } }]);
{ name: JobName.SmartSearch, data: { id: assetStub.image.id } },
]);
expect(mocks.assetJob.streamForEncodeClip).toHaveBeenCalledWith(true); expect(mocks.assetJob.streamForEncodeClip).toHaveBeenCalledWith(true);
expect(mocks.database.setDimensionSize).toHaveBeenCalledExactlyOnceWith(512); expect(mocks.database.setDimensionSize).toHaveBeenCalledExactlyOnceWith(512);
}); });
@ -190,34 +188,36 @@ describe(SmartInfoService.name, () => {
}); });
it('should skip assets without a resize path', async () => { 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.search.upsert).not.toHaveBeenCalled();
expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled(); expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled();
}); });
it('should save the returned objects', async () => { 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.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( expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith(
'/uploads/user-id/thumbs/path.jpg', asset.files[0].path,
expect.objectContaining({ modelName: 'ViT-B-32__openai' }), 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 () => { it('should skip invisible assets', async () => {
mocks.assetJob.getForClipEncoding.mockResolvedValue({ const asset = AssetFactory.from({ visibility: AssetVisibility.Hidden })
...assetStub.livePhotoMotionAsset, .file({ type: AssetFileType.Preview })
files: [assetStub.image.files[1]], .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.machineLearning.encodeImage).not.toHaveBeenCalled();
expect(mocks.search.upsert).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 () => { it('should fail if asset could not be found', async () => {
mocks.assetJob.getForClipEncoding.mockResolvedValue(void 0); 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.machineLearning.encodeImage).not.toHaveBeenCalled();
expect(mocks.search.upsert).not.toHaveBeenCalled(); expect(mocks.search.upsert).not.toHaveBeenCalled();
}); });
it('should wait for database', async () => { 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.machineLearning.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]');
mocks.database.isBusy.mockReturnValue(true); 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.database.wait).toHaveBeenCalledWith(512);
expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith( expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith(
'/uploads/user-id/thumbs/path.jpg', asset.files[0].path,
expect.objectContaining({ modelName: 'ViT-B-32__openai' }), 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]');
}); });
}); });

View file

@ -1,6 +1,7 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { StackService } from 'src/services/stack.service'; 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 { authStub } from 'test/fixtures/auth.stub';
import { newUuid } from 'test/small.factory'; import { newUuid } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService, ServiceMocks } from 'test/utils';
@ -19,38 +20,36 @@ describe(StackService.name, () => {
describe('search', () => { describe('search', () => {
it('should search stacks', async () => { 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({ expect(mocks.stack.search).toHaveBeenCalledWith({
ownerId: authStub.admin.user.id, ownerId: authStub.admin.user.id,
primaryAssetId: assetStub.image.id, primaryAssetId: asset.id,
}); });
}); });
}); });
describe('create', () => { describe('create', () => {
it('should require asset.update permissions', async () => { it('should require asset.update permissions', async () => {
await expect( const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
sut.create(authStub.admin, { assetIds: [assetStub.image.id, assetStub.image1.id] }), await expect(sut.create(authStub.admin, { assetIds: [primaryAsset.id, asset.id] })).rejects.toBeInstanceOf(
).rejects.toBeInstanceOf(BadRequestException); BadRequestException,
);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalled(); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalled();
expect(mocks.stack.create).not.toHaveBeenCalled(); expect(mocks.stack.create).not.toHaveBeenCalled();
}); });
it('should create a stack', async () => { it('should create a stack', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id, assetStub.image1.id])); const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
mocks.stack.create.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([primaryAsset.id, asset.id]));
await expect( mocks.stack.create.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset]));
sut.create(authStub.admin, { assetIds: [assetStub.image.id, assetStub.image1.id] }), await expect(sut.create(authStub.admin, { assetIds: [primaryAsset.id, asset.id] })).resolves.toEqual({
).resolves.toEqual({
id: 'stack-id', id: 'stack-id',
primaryAssetId: assetStub.image.id, primaryAssetId: primaryAsset.id,
assets: [ assets: [expect.objectContaining({ id: primaryAsset.id }), expect.objectContaining({ id: asset.id })],
expect.objectContaining({ id: assetStub.image.id }),
expect.objectContaining({ id: assetStub.image1.id }),
],
}); });
expect(mocks.event.emit).toHaveBeenCalledWith('StackCreate', { expect(mocks.event.emit).toHaveBeenCalledWith('StackCreate', {
@ -79,16 +78,14 @@ describe(StackService.name, () => {
}); });
it('should get stack', async () => { it('should get stack', async () => {
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); 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({ await expect(sut.get(authStub.admin, 'stack-id')).resolves.toEqual({
id: 'stack-id', id: 'stack-id',
primaryAssetId: assetStub.image.id, primaryAssetId: primaryAsset.id,
assets: [ assets: [expect.objectContaining({ id: primaryAsset.id }), expect.objectContaining({ id: asset.id })],
expect.objectContaining({ id: assetStub.image.id }),
expect.objectContaining({ id: assetStub.image1.id }),
],
}); });
expect(mocks.access.stack.checkOwnerAccess).toHaveBeenCalled(); expect(mocks.access.stack.checkOwnerAccess).toHaveBeenCalled();
expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id'); 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 () => { 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.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( await expect(sut.update(authStub.admin, 'stack-id', { primaryAssetId: 'unknown-asset' })).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
@ -128,16 +126,17 @@ describe(StackService.name, () => {
}); });
it('should update stack', async () => { it('should update stack', async () => {
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); 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]));
mocks.stack.update.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); 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.getById).toHaveBeenCalledWith('stack-id');
expect(mocks.stack.update).toHaveBeenCalledWith('stack-id', { expect(mocks.stack.update).toHaveBeenCalledWith('stack-id', {
id: 'stack-id', id: 'stack-id',
primaryAssetId: assetStub.image1.id, primaryAssetId: asset.id,
}); });
expect(mocks.event.emit).toHaveBeenCalledWith('StackUpdate', { expect(mocks.event.emit).toHaveBeenCalledWith('StackUpdate', {
stackId: 'stack-id', stackId: 'stack-id',
@ -214,24 +213,26 @@ describe(StackService.name, () => {
}); });
it('should fail if the assetId is the primaryAssetId', async () => { it('should fail if the assetId is the primaryAssetId', async () => {
const asset = AssetFactory.create();
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); 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( await expect(sut.removeAsset(authStub.admin, { id: 'stack-id', assetId: asset.id })).rejects.toBeInstanceOf(
sut.removeAsset(authStub.admin, { id: 'stack-id', assetId: assetStub.image.id }), BadRequestException,
).rejects.toBeInstanceOf(BadRequestException); );
expect(mocks.asset.update).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalled();
expect(mocks.event.emit).not.toHaveBeenCalled(); expect(mocks.event.emit).not.toHaveBeenCalled();
}); });
it("should update the asset to nullify it's stack-id", async () => { 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.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', { expect(mocks.event.emit).toHaveBeenCalledWith('StackUpdate', {
stackId: 'stack-id', stackId: 'stack-id',
userId: authStub.admin.user.id, userId: authStub.admin.user.id,

View file

@ -478,9 +478,9 @@ describe(StorageTemplateService.name, () => {
mocks.user.getList.mockResolvedValue([userStub.user1]); mocks.user.getList.mockResolvedValue([userStub.user1]);
mocks.move.create.mockResolvedValue({ mocks.move.create.mockResolvedValue({
id: '123', id: '123',
entityId: assetStub.image.id, entityId: asset.id,
pathType: AssetPathType.Original, pathType: AssetPathType.Original,
oldPath: assetStub.image.originalPath, oldPath: asset.originalPath,
newPath, newPath,
}); });

View file

@ -1,5 +1,6 @@
import { mapAsset } from 'src/dtos/asset-response.dto'; import { mapAsset } from 'src/dtos/asset-response.dto';
import { SyncService } from 'src/services/sync.service'; import { SyncService } from 'src/services/sync.service';
import { AssetFactory } from 'test/factories/asset.factory';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { factory } from 'test/small.factory'; 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 () => { 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.partner.getAll.mockResolvedValue([]);
mocks.asset.getChangedDeltaSync.mockResolvedValue( mocks.asset.getChangedDeltaSync.mockResolvedValue(Array.from<typeof asset>({ length: 10_000 }).fill(asset));
Array.from<typeof assetStub.image>({ length: 10_000 }).fill(assetStub.image),
);
await expect( await expect(
sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }), sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }),
).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] }); ).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] });
@ -72,14 +72,15 @@ describe(SyncService.name, () => {
}); });
it('should return a response with changes and deletions', async () => { it('should return a response with changes and deletions', async () => {
const asset = AssetFactory.create({ ownerId: authStub.user1.user.id });
mocks.partner.getAll.mockResolvedValue([]); mocks.partner.getAll.mockResolvedValue([]);
mocks.asset.getChangedDeltaSync.mockResolvedValue([assetStub.image1]); mocks.asset.getChangedDeltaSync.mockResolvedValue([asset]);
mocks.audit.getAfter.mockResolvedValue([assetStub.external.id]); mocks.audit.getAfter.mockResolvedValue([assetStub.external.id]);
await expect( await expect(
sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }), sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }),
).resolves.toEqual({ ).resolves.toEqual({
needsFullSync: false, needsFullSync: false,
upserted: [mapAsset(assetStub.image1, mapAssetOpts)], upserted: [mapAsset(asset, mapAssetOpts)],
deleted: [assetStub.external.id], deleted: [assetStub.external.id],
}); });
expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(1); expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(1);

View file

@ -1,6 +1,6 @@
import { mapAsset } from 'src/dtos/asset-response.dto'; import { mapAsset } from 'src/dtos/asset-response.dto';
import { ViewService } from 'src/services/view.service'; 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 { authStub } from 'test/fixtures/auth.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService, ServiceMocks } from 'test/utils';
@ -32,8 +32,8 @@ describe(ViewService.name, () => {
it('should return assets by original path', async () => { it('should return assets by original path', async () => {
const path = '/asset'; const path = '/asset';
const asset1 = { ...assetStub.image, originalPath: '/asset/path1' }; const asset1 = AssetFactory.create({ originalPath: '/asset/path1' });
const asset2 = { ...assetStub.image, originalPath: '/asset/path2' }; const asset2 = AssetFactory.create({ originalPath: '/asset/path2' });
const mockAssets = [asset1, asset2]; const mockAssets = [asset1, asset2];

View file

@ -15,6 +15,7 @@ export class AssetFactory {
#assetExif?: AssetExifFactory; #assetExif?: AssetExifFactory;
#files: AssetFileFactory[] = []; #files: AssetFileFactory[] = [];
#edits: AssetEditFactory[] = []; #edits: AssetEditFactory[] = [];
#faces: Selectable<AssetFaceTable>[] = [];
private constructor(private readonly value: Selectable<AssetTable>) { private constructor(private readonly value: Selectable<AssetTable>) {
value.ownerId ??= newUuid(); value.ownerId ??= newUuid();
@ -82,6 +83,11 @@ export class AssetFactory {
return this; return this;
} }
face(dto: Selectable<AssetFaceTable>) {
this.#faces.push(dto);
return this;
}
file(dto: AssetFileLike = {}, builder?: FactoryBuilder<AssetFileFactory>) { file(dto: AssetFileLike = {}, builder?: FactoryBuilder<AssetFileFactory>) {
this.#files.push(build(AssetFileFactory.from(dto).asset(this.value), builder)); this.#files.push(build(AssetFileFactory.from(dto).asset(this.value), builder));
return this; return this;
@ -120,7 +126,8 @@ export class AssetFactory {
exifInfo: exif as NonNullable<typeof exif>, exifInfo: exif as NonNullable<typeof exif>,
files: this.#files.map((file) => file.build()), files: this.#files.map((file) => file.build()),
edits: this.#edits.map((edit) => edit.build()), edits: this.#edits.map((edit) => edit.build()),
faces: [] as Selectable<AssetFaceTable>[], faces: this.#faces,
stack: null,
}; };
} }
} }

View file

@ -2,14 +2,16 @@ import { Selectable } from 'kysely';
import { SharedLinkType } from 'src/enum'; import { SharedLinkType } from 'src/enum';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
import { AlbumFactory } from 'test/factories/album.factory'; import { AlbumFactory } from 'test/factories/album.factory';
import { AssetFactory } from 'test/factories/asset.factory';
import { build } from 'test/factories/builder.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 { UserFactory } from 'test/factories/user.factory';
import { factory, newDate, newUuid } from 'test/small.factory'; import { factory, newDate, newUuid } from 'test/small.factory';
export class SharedLinkFactory { export class SharedLinkFactory {
#owner: UserFactory; #owner: UserFactory;
#album?: AlbumFactory; #album?: AlbumFactory;
#assets: AssetFactory[] = [];
private constructor(private readonly value: Selectable<SharedLinkTable>) { private constructor(private readonly value: Selectable<SharedLinkTable>) {
value.userId ??= newUuid(); value.userId ??= newUuid();
@ -52,12 +54,18 @@ export class SharedLinkFactory {
return this; return this;
} }
asset(dto: AssetLike = {}, builder?: FactoryBuilder<AssetFactory>) {
const asset = build(AssetFactory.from(dto), builder);
this.#assets.push(asset);
return this;
}
build() { build() {
return { return {
...this.value, ...this.value,
owner: this.#owner.build(), owner: this.#owner.build(),
album: this.#album?.build(), album: this.#album?.build() ?? null,
assets: [], assets: this.#assets.map((asset) => asset.build()),
}; };
} }
} }

View file

@ -55,45 +55,6 @@ export const assetStub = {
isEdited: false, isEdited: false,
...asset, ...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({ primaryImage: Object.freeze({
id: 'primary-asset-id', id: 'primary-asset-id',
@ -144,53 +105,6 @@ export const assetStub = {
isEdited: false, 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({ trashed: Object.freeze({
id: 'asset-id', id: 'asset-id',
deviceAssetId: 'device-asset-id', deviceAssetId: 'device-asset-id',
@ -365,49 +279,6 @@ export const assetStub = {
isEdited: false, 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({ video: Object.freeze({
id: 'asset-id', id: 'asset-id',
status: AssetStatus.Active, status: AssetStatus.Active,

View file

@ -1,13 +1,13 @@
import { SourceType } from 'src/enum'; 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'; import { personStub } from 'test/fixtures/person.stub';
export const faceStub = { export const faceStub = {
face1: Object.freeze({ face1: Object.freeze({
id: 'assetFaceId1', id: 'assetFaceId1',
assetId: assetStub.image.id, assetId: 'asset-id',
asset: { asset: {
...assetStub.image, ...AssetFactory.create({ id: 'asset-id' }),
libraryId: null, libraryId: null,
updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125',
stackId: null, stackId: null,
@ -29,8 +29,8 @@ export const faceStub = {
}), }),
primaryFace1: Object.freeze({ primaryFace1: Object.freeze({
id: 'assetFaceId2', id: 'assetFaceId2',
assetId: assetStub.image.id, assetId: 'asset-id',
asset: assetStub.image, asset: AssetFactory.create({ id: 'asset-id' }),
personId: personStub.primaryPerson.id, personId: personStub.primaryPerson.id,
person: personStub.primaryPerson, person: personStub.primaryPerson,
boundingBoxX1: 0, boundingBoxX1: 0,
@ -48,8 +48,8 @@ export const faceStub = {
}), }),
mergeFace1: Object.freeze({ mergeFace1: Object.freeze({
id: 'assetFaceId3', id: 'assetFaceId3',
assetId: assetStub.image.id, assetId: 'asset-id',
asset: assetStub.image, asset: AssetFactory.create({ id: 'asset-id' }),
personId: personStub.mergePerson.id, personId: personStub.mergePerson.id,
person: personStub.mergePerson, person: personStub.mergePerson,
boundingBoxX1: 0, boundingBoxX1: 0,
@ -67,8 +67,8 @@ export const faceStub = {
}), }),
noPerson1: Object.freeze({ noPerson1: Object.freeze({
id: 'assetFaceId8', id: 'assetFaceId8',
assetId: assetStub.image.id, assetId: 'asset-id',
asset: assetStub.image, asset: AssetFactory.create({ id: 'asset-id' }),
personId: null, personId: null,
person: null, person: null,
boundingBoxX1: 0, boundingBoxX1: 0,
@ -86,8 +86,8 @@ export const faceStub = {
}), }),
noPerson2: Object.freeze({ noPerson2: Object.freeze({
id: 'assetFaceId9', id: 'assetFaceId9',
assetId: assetStub.image.id, assetId: 'asset-id',
asset: assetStub.image, asset: AssetFactory.create({ id: 'asset-id' }),
personId: null, personId: null,
person: null, person: null,
boundingBoxX1: 0, boundingBoxX1: 0,
@ -105,8 +105,8 @@ export const faceStub = {
}), }),
fromExif1: Object.freeze({ fromExif1: Object.freeze({
id: 'assetFaceId9', id: 'assetFaceId9',
assetId: assetStub.image.id, assetId: 'asset-id',
asset: assetStub.image, asset: AssetFactory.create({ id: 'asset-id' }),
personId: personStub.randomPerson.id, personId: personStub.randomPerson.id,
person: personStub.randomPerson, person: personStub.randomPerson,
boundingBoxX1: 100, boundingBoxX1: 100,
@ -123,8 +123,8 @@ export const faceStub = {
}), }),
fromExif2: Object.freeze({ fromExif2: Object.freeze({
id: 'assetFaceId9', id: 'assetFaceId9',
assetId: assetStub.image.id, assetId: 'asset-id',
asset: assetStub.image, asset: AssetFactory.create({ id: 'asset-id' }),
personId: personStub.randomPerson.id, personId: personStub.randomPerson.id,
person: personStub.randomPerson, person: personStub.randomPerson,
boundingBoxX1: 0, boundingBoxX1: 0,
@ -141,8 +141,8 @@ export const faceStub = {
}), }),
withBirthDate: Object.freeze({ withBirthDate: Object.freeze({
id: 'assetFaceId10', id: 'assetFaceId10',
assetId: assetStub.image.id, assetId: 'asset-id',
asset: assetStub.image, asset: AssetFactory.create({ id: 'asset-id' }),
personId: personStub.withBirthDate.id, personId: personStub.withBirthDate.id,
person: personStub.withBirthDate, person: personStub.withBirthDate,
boundingBoxX1: 0, boundingBoxX1: 0,

View file

@ -2,7 +2,7 @@ import { UserAdmin } from 'src/database';
import { MapAsset } from 'src/dtos/asset-response.dto'; import { MapAsset } from 'src/dtos/asset-response.dto';
import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto'; import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto';
import { AssetStatus, AssetType, AssetVisibility, SharedLinkType } from 'src/enum'; 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 { authStub } from 'test/fixtures/auth.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
@ -31,7 +31,7 @@ export const sharedLinkStub = {
albumId: null, albumId: null,
album: null, album: null,
description: null, description: null,
assets: [assetStub.image], assets: [AssetFactory.create()],
password: 'password', password: 'password',
slug: null, slug: null,
}), }),