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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ export class AssetFactory {
#assetExif?: AssetExifFactory;
#files: AssetFileFactory[] = [];
#edits: AssetEditFactory[] = [];
#faces: Selectable<AssetFaceTable>[] = [];
private constructor(private readonly value: Selectable<AssetTable>) {
value.ownerId ??= newUuid();
@@ -82,6 +83,11 @@ export class AssetFactory {
return this;
}
face(dto: Selectable<AssetFaceTable>) {
this.#faces.push(dto);
return this;
}
file(dto: AssetFileLike = {}, builder?: FactoryBuilder<AssetFileFactory>) {
this.#files.push(build(AssetFileFactory.from(dto).asset(this.value), builder));
return this;
@@ -120,7 +126,8 @@ export class AssetFactory {
exifInfo: exif as NonNullable<typeof exif>,
files: this.#files.map((file) => file.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 { SharedLinkTable } from 'src/schema/tables/shared-link.table';
import { AlbumFactory } from 'test/factories/album.factory';
import { AssetFactory } from 'test/factories/asset.factory';
import { build } from 'test/factories/builder.factory';
import { AlbumLike, FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types';
import { AlbumLike, AssetLike, FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types';
import { UserFactory } from 'test/factories/user.factory';
import { factory, newDate, newUuid } from 'test/small.factory';
export class SharedLinkFactory {
#owner: UserFactory;
#album?: AlbumFactory;
#assets: AssetFactory[] = [];
private constructor(private readonly value: Selectable<SharedLinkTable>) {
value.userId ??= newUuid();
@@ -52,12 +54,18 @@ export class SharedLinkFactory {
return this;
}
asset(dto: AssetLike = {}, builder?: FactoryBuilder<AssetFactory>) {
const asset = build(AssetFactory.from(dto), builder);
this.#assets.push(asset);
return this;
}
build() {
return {
...this.value,
owner: this.#owner.build(),
album: this.#album?.build(),
assets: [],
album: this.#album?.build() ?? null,
assets: this.#assets.map((asset) => asset.build()),
};
}
}

View File

@@ -55,45 +55,6 @@ export const assetStub = {
isEdited: false,
...asset,
}),
noResizePath: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
originalFileName: 'IMG_123.jpg',
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/data/library/IMG_123.jpg',
files: [thumbnailFile],
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
duration: null,
livePhotoVideo: null,
livePhotoVideoId: null,
sharedLinks: [],
faces: [],
exifInfo: {} as Exif,
deletedAt: null,
isExternal: false,
duplicateId: null,
isOffline: false,
libraryId: null,
stackId: null,
updateId: '42',
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
isEdited: false,
}),
primaryImage: Object.freeze({
id: 'primary-asset-id',
@@ -144,53 +105,6 @@ export const assetStub = {
isEdited: false,
}),
image: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.jpg',
files,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2025-01-01T01:02:03.456Z'),
isFavorite: true,
duration: null,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
updateId: 'foo',
libraryId: null,
stackId: null,
sharedLinks: [],
originalFileName: 'asset-id.jpg',
faces: [],
deletedAt: null,
exifInfo: {
fileSizeInByte: 5000,
exifImageHeight: 3840,
exifImageWidth: 2160,
} as Exif,
duplicateId: null,
isOffline: false,
stack: null,
orientation: '',
projectionType: null,
height: null,
width: null,
visibility: AssetVisibility.Timeline,
edits: [],
isEdited: false,
}),
trashed: Object.freeze({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
@@ -365,49 +279,6 @@ export const assetStub = {
isEdited: false,
}),
image1: Object.freeze({
id: 'asset-id-1',
status: AssetStatus.Active,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.ext',
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
files,
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
deletedAt: null,
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
duration: null,
livePhotoVideo: null,
livePhotoVideoId: null,
isExternal: false,
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
exifInfo: {
fileSizeInByte: 5000,
} as Exif,
duplicateId: null,
isOffline: false,
updateId: '42',
stackId: null,
libraryId: null,
stack: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
isEdited: false,
}),
video: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,

View File

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

View File

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