mirror of
https://github.com/immich-app/immich.git
synced 2026-03-01 10:08:42 +03:00
796 lines
30 KiB
TypeScript
Executable File
796 lines
30 KiB
TypeScript
Executable File
import { BadRequestException } from '@nestjs/common';
|
|
import { DateTime } from 'luxon';
|
|
import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto';
|
|
import { AssetEditAction } from 'src/dtos/editing.dto';
|
|
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';
|
|
import { AuthFactory } from 'test/factories/auth.factory';
|
|
import { assetStub } from 'test/fixtures/asset.stub';
|
|
import { authStub } from 'test/fixtures/auth.stub';
|
|
import { factory, newUuid } from 'test/small.factory';
|
|
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
|
|
|
const stats: AssetStats = {
|
|
[AssetType.Image]: 10,
|
|
[AssetType.Video]: 23,
|
|
[AssetType.Audio]: 0,
|
|
[AssetType.Other]: 0,
|
|
};
|
|
|
|
const statResponse: AssetStatsResponseDto = {
|
|
images: 10,
|
|
videos: 23,
|
|
total: 33,
|
|
};
|
|
|
|
describe(AssetService.name, () => {
|
|
let sut: AssetService;
|
|
let mocks: ServiceMocks;
|
|
|
|
it('should work', () => {
|
|
expect(sut).toBeDefined();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
({ sut, mocks } = newTestService(AssetService));
|
|
});
|
|
|
|
describe('getStatistics', () => {
|
|
it('should get the statistics for a user, excluding archived assets', async () => {
|
|
const auth = AuthFactory.create();
|
|
mocks.asset.getStatistics.mockResolvedValue(stats);
|
|
await expect(sut.getStatistics(auth, { visibility: AssetVisibility.Timeline })).resolves.toEqual(statResponse);
|
|
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(auth.user.id, { visibility: AssetVisibility.Timeline });
|
|
});
|
|
|
|
it('should get the statistics for a user for archived assets', async () => {
|
|
const auth = AuthFactory.create();
|
|
mocks.asset.getStatistics.mockResolvedValue(stats);
|
|
await expect(sut.getStatistics(auth, { visibility: AssetVisibility.Archive })).resolves.toEqual(statResponse);
|
|
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(auth.user.id, {
|
|
visibility: AssetVisibility.Archive,
|
|
});
|
|
});
|
|
|
|
it('should get the statistics for a user for favorite assets', async () => {
|
|
const auth = AuthFactory.create();
|
|
mocks.asset.getStatistics.mockResolvedValue(stats);
|
|
await expect(sut.getStatistics(auth, { isFavorite: true })).resolves.toEqual(statResponse);
|
|
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(auth.user.id, { isFavorite: true });
|
|
});
|
|
|
|
it('should get the statistics for a user for all assets', async () => {
|
|
const auth = AuthFactory.create();
|
|
mocks.asset.getStatistics.mockResolvedValue(stats);
|
|
await expect(sut.getStatistics(auth, {})).resolves.toEqual(statResponse);
|
|
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(auth.user.id, {});
|
|
});
|
|
});
|
|
|
|
describe('getRandom', () => {
|
|
it('should get own random assets', async () => {
|
|
mocks.partner.getAll.mockResolvedValue([]);
|
|
mocks.asset.getRandom.mockResolvedValue([AssetFactory.create()]);
|
|
|
|
await sut.getRandom(authStub.admin, 1);
|
|
|
|
expect(mocks.asset.getRandom).toHaveBeenCalledWith([authStub.admin.user.id], 1);
|
|
});
|
|
|
|
it('should not include partner assets if not in timeline', async () => {
|
|
const partner = factory.partner({ inTimeline: false });
|
|
const auth = factory.auth({ user: { id: partner.sharedWithId } });
|
|
|
|
mocks.asset.getRandom.mockResolvedValue([AssetFactory.create()]);
|
|
mocks.partner.getAll.mockResolvedValue([partner]);
|
|
|
|
await sut.getRandom(auth, 1);
|
|
|
|
expect(mocks.asset.getRandom).toHaveBeenCalledWith([auth.user.id], 1);
|
|
});
|
|
|
|
it('should include partner assets if in timeline', async () => {
|
|
const partner = factory.partner({ inTimeline: true });
|
|
const auth = factory.auth({ user: { id: partner.sharedWithId } });
|
|
|
|
mocks.asset.getRandom.mockResolvedValue([AssetFactory.create()]);
|
|
mocks.partner.getAll.mockResolvedValue([partner]);
|
|
|
|
await sut.getRandom(auth, 1);
|
|
|
|
expect(mocks.asset.getRandom).toHaveBeenCalledWith([auth.user.id, partner.sharedById], 1);
|
|
});
|
|
});
|
|
|
|
describe('get', () => {
|
|
it('should allow owner access', async () => {
|
|
const asset = AssetFactory.create();
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
|
mocks.asset.getById.mockResolvedValue(asset);
|
|
|
|
await sut.get(authStub.admin, asset.id);
|
|
|
|
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
|
|
authStub.admin.user.id,
|
|
new Set([asset.id]),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should allow shared link access', async () => {
|
|
const asset = AssetFactory.create();
|
|
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([asset.id]));
|
|
mocks.asset.getById.mockResolvedValue(asset);
|
|
|
|
await sut.get(authStub.adminSharedLink, asset.id);
|
|
|
|
expect(mocks.access.asset.checkSharedLinkAccess).toHaveBeenCalledWith(
|
|
authStub.adminSharedLink.sharedLink?.id,
|
|
new Set([asset.id]),
|
|
);
|
|
});
|
|
|
|
it('should strip metadata for shared link if exif is disabled', async () => {
|
|
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 } },
|
|
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([asset.id]),
|
|
);
|
|
});
|
|
|
|
it('should allow partner sharing access', async () => {
|
|
const asset = AssetFactory.create();
|
|
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset.id]));
|
|
mocks.asset.getById.mockResolvedValue(asset);
|
|
|
|
await sut.get(authStub.admin, asset.id);
|
|
|
|
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set([asset.id]));
|
|
});
|
|
|
|
it('should allow shared album access', async () => {
|
|
const asset = AssetFactory.create();
|
|
mocks.access.asset.checkAlbumAccess.mockResolvedValue(new Set([asset.id]));
|
|
mocks.asset.getById.mockResolvedValue(asset);
|
|
|
|
await sut.get(authStub.admin, asset.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, 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, 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 () => {
|
|
const asset = AssetFactory.create();
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
|
|
|
await expect(sut.get(authStub.admin, asset.id)).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
});
|
|
|
|
describe('update', () => {
|
|
it('should require asset write access for the id', async () => {
|
|
await expect(
|
|
sut.update(authStub.admin, 'asset-1', { visibility: AssetVisibility.Timeline }),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
|
|
expect(mocks.asset.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should update the asset', async () => {
|
|
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.id, { isFavorite: true });
|
|
|
|
expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, isFavorite: true });
|
|
});
|
|
|
|
it('should update the exif description', async () => {
|
|
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.id, { description: 'Test description' });
|
|
|
|
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
|
{ assetId: asset.id, description: 'Test description', lockedProperties: ['description'] },
|
|
{ lockedPropertiesBehavior: 'append' },
|
|
);
|
|
});
|
|
|
|
it('should update the exif rating', async () => {
|
|
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.id, { rating: 3 });
|
|
|
|
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
|
{
|
|
assetId: asset.id,
|
|
rating: 3,
|
|
lockedProperties: ['rating'],
|
|
},
|
|
{ lockedPropertiesBehavior: 'append' },
|
|
);
|
|
});
|
|
|
|
it('should fail linking a live video if the motion part could not be found', async () => {
|
|
const auth = AuthFactory.create();
|
|
const asset = AssetFactory.create();
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
|
|
|
await expect(
|
|
sut.update(auth, asset.id, {
|
|
livePhotoVideoId: 'unknown',
|
|
}),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
|
|
expect(mocks.asset.update).not.toHaveBeenCalledWith({
|
|
id: asset.id,
|
|
livePhotoVideoId: 'unknown',
|
|
});
|
|
expect(mocks.asset.update).not.toHaveBeenCalledWith({
|
|
id: 'unknown',
|
|
visibility: AssetVisibility.Timeline,
|
|
});
|
|
expect(mocks.event.emit).not.toHaveBeenCalledWith('AssetShow', {
|
|
assetId: 'unknown',
|
|
userId: auth.user.id,
|
|
});
|
|
});
|
|
|
|
it('should fail linking a live video if the motion part is not a video', async () => {
|
|
const auth = AuthFactory.create();
|
|
const motionAsset = AssetFactory.from().owner(auth.user).build();
|
|
const asset = AssetFactory.create();
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
|
mocks.asset.getById.mockResolvedValue(asset);
|
|
|
|
await expect(
|
|
sut.update(authStub.admin, asset.id, {
|
|
livePhotoVideoId: motionAsset.id,
|
|
}),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
|
|
expect(mocks.asset.update).not.toHaveBeenCalledWith({
|
|
id: asset.id,
|
|
livePhotoVideoId: motionAsset.id,
|
|
});
|
|
expect(mocks.asset.update).not.toHaveBeenCalledWith({
|
|
id: motionAsset.id,
|
|
visibility: AssetVisibility.Timeline,
|
|
});
|
|
expect(mocks.event.emit).not.toHaveBeenCalledWith('AssetShow', {
|
|
assetId: motionAsset.id,
|
|
userId: auth.user.id,
|
|
});
|
|
});
|
|
|
|
it('should fail linking a live video if the motion part has a different owner', async () => {
|
|
const auth = AuthFactory.create();
|
|
const motionAsset = AssetFactory.create({ type: AssetType.Video });
|
|
const asset = AssetFactory.create();
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
|
mocks.asset.getById.mockResolvedValue(motionAsset);
|
|
|
|
await expect(
|
|
sut.update(auth, asset.id, {
|
|
livePhotoVideoId: motionAsset.id,
|
|
}),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
|
|
expect(mocks.asset.update).not.toHaveBeenCalledWith({
|
|
id: asset.id,
|
|
livePhotoVideoId: motionAsset.id,
|
|
});
|
|
expect(mocks.asset.update).not.toHaveBeenCalledWith({
|
|
id: motionAsset.id,
|
|
visibility: AssetVisibility.Timeline,
|
|
});
|
|
expect(mocks.event.emit).not.toHaveBeenCalledWith('AssetShow', {
|
|
assetId: motionAsset.id,
|
|
userId: auth.user.id,
|
|
});
|
|
});
|
|
|
|
it('should link a live video', async () => {
|
|
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Timeline });
|
|
const stillAsset = AssetFactory.create();
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([stillAsset.id]));
|
|
mocks.asset.getById.mockResolvedValueOnce(motionAsset);
|
|
mocks.asset.getById.mockResolvedValueOnce(stillAsset);
|
|
mocks.asset.update.mockResolvedValue(stillAsset);
|
|
const auth = AuthFactory.from(motionAsset.owner).build();
|
|
|
|
await sut.update(auth, stillAsset.id, { livePhotoVideoId: motionAsset.id });
|
|
|
|
expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, visibility: AssetVisibility.Hidden });
|
|
expect(mocks.event.emit).toHaveBeenCalledWith('AssetHide', { assetId: motionAsset.id, userId: auth.user.id });
|
|
expect(mocks.asset.update).toHaveBeenCalledWith({ id: stillAsset.id, livePhotoVideoId: motionAsset.id });
|
|
});
|
|
|
|
it('should throw an error if asset could not be found after update', async () => {
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
|
await expect(sut.update(AuthFactory.create(), 'asset-1', { isFavorite: true })).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
});
|
|
|
|
it('should unlink a live video', async () => {
|
|
const auth = AuthFactory.create();
|
|
const motionAsset = AssetFactory.from({ type: AssetType.Video, visibility: AssetVisibility.Hidden })
|
|
.owner(auth.user)
|
|
.build();
|
|
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
|
|
const unlinkedAsset = AssetFactory.create();
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
|
mocks.asset.getById.mockResolvedValueOnce(asset);
|
|
mocks.asset.getById.mockResolvedValueOnce(motionAsset);
|
|
mocks.asset.update.mockResolvedValueOnce(unlinkedAsset);
|
|
|
|
await sut.update(auth, asset.id, { livePhotoVideoId: null });
|
|
|
|
expect(mocks.asset.update).toHaveBeenCalledWith({
|
|
id: asset.id,
|
|
livePhotoVideoId: null,
|
|
});
|
|
expect(mocks.asset.update).toHaveBeenCalledWith({
|
|
id: motionAsset.id,
|
|
visibility: asset.visibility,
|
|
});
|
|
expect(mocks.event.emit).toHaveBeenCalledWith('AssetShow', {
|
|
assetId: motionAsset.id,
|
|
userId: auth.user.id,
|
|
});
|
|
});
|
|
|
|
it('should fail unlinking a live video if the asset could not be found', async () => {
|
|
const asset = AssetFactory.create();
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
|
mocks.asset.getById.mockResolvedValueOnce(void 0);
|
|
|
|
await expect(sut.update(authStub.admin, asset.id, { livePhotoVideoId: null })).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
|
|
expect(mocks.asset.update).not.toHaveBeenCalled();
|
|
expect(mocks.event.emit).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('updateAll', () => {
|
|
it('should require asset write access for all ids', async () => {
|
|
const auth = AuthFactory.create();
|
|
await expect(sut.updateAll(auth, { ids: ['asset-1'] })).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it('should update all assets', async () => {
|
|
const auth = AuthFactory.create();
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2']));
|
|
|
|
await sut.updateAll(auth, { ids: ['asset-1', 'asset-2'], visibility: AssetVisibility.Archive });
|
|
|
|
expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], {
|
|
visibility: AssetVisibility.Archive,
|
|
});
|
|
});
|
|
|
|
it('should not update Assets table if no relevant fields are provided', async () => {
|
|
const auth = AuthFactory.create();
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
|
|
|
await sut.updateAll(auth, {
|
|
ids: ['asset-1'],
|
|
latitude: 0,
|
|
longitude: 0,
|
|
isFavorite: undefined,
|
|
duplicateId: undefined,
|
|
rating: undefined,
|
|
});
|
|
expect(mocks.asset.updateAll).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should update Assets table if visibility field is provided', async () => {
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
|
|
|
await sut.updateAll(authStub.admin, {
|
|
ids: ['asset-1'],
|
|
latitude: 0,
|
|
longitude: 0,
|
|
visibility: AssetVisibility.Archive,
|
|
isFavorite: false,
|
|
duplicateId: undefined,
|
|
rating: undefined,
|
|
});
|
|
expect(mocks.asset.updateAll).toHaveBeenCalled();
|
|
expect(mocks.asset.updateAllExif).toHaveBeenCalledWith(['asset-1'], { latitude: 0, longitude: 0 });
|
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SidecarWrite, data: { id: 'asset-1' } }]);
|
|
});
|
|
|
|
it('should update exif table if latitude field is provided', async () => {
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
|
const dateTimeOriginal = new Date().toISOString();
|
|
await sut.updateAll(authStub.admin, {
|
|
ids: ['asset-1'],
|
|
latitude: 30,
|
|
longitude: 50,
|
|
dateTimeOriginal,
|
|
isFavorite: false,
|
|
duplicateId: undefined,
|
|
rating: undefined,
|
|
});
|
|
expect(mocks.asset.updateAll).toHaveBeenCalled();
|
|
expect(mocks.asset.updateAllExif).toHaveBeenCalledWith(['asset-1'], {
|
|
dateTimeOriginal,
|
|
latitude: 30,
|
|
longitude: 50,
|
|
});
|
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SidecarWrite, data: { id: 'asset-1' } }]);
|
|
});
|
|
|
|
it('should update Assets table if duplicateId is provided as null', async () => {
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
|
|
|
await sut.updateAll(authStub.admin, {
|
|
ids: ['asset-1'],
|
|
latitude: 0,
|
|
longitude: 0,
|
|
isFavorite: undefined,
|
|
duplicateId: null,
|
|
rating: undefined,
|
|
});
|
|
expect(mocks.asset.updateAll).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should update exif table if dateTimeRelative and timeZone field is provided', async () => {
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
|
const dateTimeRelative = 35;
|
|
const timeZone = 'UTC+2';
|
|
mocks.asset.updateDateTimeOriginal.mockResolvedValue([
|
|
{ assetId: 'asset-1', dateTimeOriginal: new Date('2020-02-25T04:41:00'), timeZone },
|
|
]);
|
|
await sut.updateAll(authStub.admin, {
|
|
ids: ['asset-1'],
|
|
dateTimeRelative,
|
|
timeZone,
|
|
});
|
|
expect(mocks.asset.updateDateTimeOriginal).toHaveBeenCalledWith(['asset-1'], dateTimeRelative, timeZone);
|
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SidecarWrite, data: { id: 'asset-1' } }]);
|
|
});
|
|
});
|
|
|
|
describe('deleteAll', () => {
|
|
it('should require asset delete access for all ids', async () => {
|
|
await expect(
|
|
sut.deleteAll(authStub.user1, {
|
|
ids: ['asset-1'],
|
|
}),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it('should force delete a batch of assets', async () => {
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2']));
|
|
|
|
await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: true });
|
|
|
|
expect(mocks.event.emit).toHaveBeenCalledWith('AssetDeleteAll', {
|
|
assetIds: ['asset1', 'asset2'],
|
|
userId: 'user-id',
|
|
});
|
|
});
|
|
|
|
it('should soft delete a batch of assets', async () => {
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2']));
|
|
|
|
await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: false });
|
|
|
|
expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset1', 'asset2'], {
|
|
deletedAt: expect.any(Date),
|
|
status: AssetStatus.Trashed,
|
|
});
|
|
expect(mocks.job.queue.mock.calls).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('handleAssetDeletionCheck', () => {
|
|
beforeAll(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterAll(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('should immediately queue assets for deletion if trash is disabled', async () => {
|
|
const asset = factory.asset({ isOffline: false });
|
|
|
|
mocks.assetJob.streamForDeletedJob.mockReturnValue(makeStream([asset]));
|
|
mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: false } });
|
|
|
|
await expect(sut.handleAssetDeletionCheck()).resolves.toBe(JobStatus.Success);
|
|
|
|
expect(mocks.assetJob.streamForDeletedJob).toHaveBeenCalledWith(new Date());
|
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
|
{ name: JobName.AssetDelete, data: { id: asset.id, deleteOnDisk: true } },
|
|
]);
|
|
});
|
|
|
|
it('should queue assets for deletion after trash duration', async () => {
|
|
const asset = factory.asset({ isOffline: false });
|
|
|
|
mocks.assetJob.streamForDeletedJob.mockReturnValue(makeStream([asset]));
|
|
mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: true, days: 7 } });
|
|
|
|
await expect(sut.handleAssetDeletionCheck()).resolves.toBe(JobStatus.Success);
|
|
|
|
expect(mocks.assetJob.streamForDeletedJob).toHaveBeenCalledWith(DateTime.now().minus({ days: 7 }).toJSDate());
|
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
|
{ name: JobName.AssetDelete, data: { id: asset.id, deleteOnDisk: true } },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('handleAssetDeletion', () => {
|
|
it('should clean up files', async () => {
|
|
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 });
|
|
|
|
expect(mocks.job.queue.mock.calls).toEqual([
|
|
[
|
|
{
|
|
name: JobName.FileDelete,
|
|
data: {
|
|
files: [...asset.files.map(({ path }) => path), asset.originalPath],
|
|
},
|
|
},
|
|
],
|
|
]);
|
|
expect(mocks.asset.remove).toHaveBeenCalledWith(asset);
|
|
});
|
|
|
|
it('should delete the entire stack if deleted asset was the primary asset and the stack would only contain one asset afterwards', async () => {
|
|
mocks.stack.delete.mockResolvedValue();
|
|
mocks.assetJob.getForAssetDeletion.mockResolvedValue({
|
|
...assetStub.primaryImage,
|
|
stack: {
|
|
id: 'stack-id',
|
|
primaryAssetId: assetStub.primaryImage.id,
|
|
assets: [{ id: 'one-asset' }],
|
|
},
|
|
});
|
|
|
|
await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true });
|
|
|
|
expect(mocks.stack.delete).toHaveBeenCalledWith('stack-id');
|
|
});
|
|
|
|
it('should delete a live photo', async () => {
|
|
const motionAsset = AssetFactory.from({ type: AssetType.Video, visibility: AssetVisibility.Hidden }).build();
|
|
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
|
|
mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset);
|
|
mocks.asset.getLivePhotoCount.mockResolvedValue(0);
|
|
|
|
await sut.handleAssetDeletion({
|
|
id: asset.id,
|
|
deleteOnDisk: true,
|
|
});
|
|
|
|
expect(mocks.job.queue.mock.calls).toEqual([
|
|
[{ name: JobName.AssetDelete, data: { id: motionAsset.id, deleteOnDisk: true } }],
|
|
[{ name: JobName.FileDelete, data: { files: [asset.originalPath] } }],
|
|
]);
|
|
});
|
|
|
|
it('should not delete a live motion part if it is being used by another asset', async () => {
|
|
const asset = AssetFactory.create({ livePhotoVideoId: newUuid() });
|
|
mocks.asset.getLivePhotoCount.mockResolvedValue(2);
|
|
mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset);
|
|
|
|
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
|
|
|
|
expect(mocks.job.queue.mock.calls).toEqual([
|
|
[{ name: JobName.FileDelete, data: { files: [`/data/library/IMG_${asset.id}.jpg`] } }],
|
|
]);
|
|
});
|
|
|
|
it('should update usage', async () => {
|
|
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: AssetFactory.create().id, deleteOnDisk: true })).resolves.toBe(
|
|
JobStatus.Failed,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getOcr', () => {
|
|
it('should require asset read permission', async () => {
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set());
|
|
|
|
await expect(sut.getOcr(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException);
|
|
|
|
expect(mocks.ocr.getByAssetId).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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.id]));
|
|
mocks.ocr.getByAssetId.mockResolvedValue([ocr1, ocr2]);
|
|
mocks.asset.getById.mockResolvedValue(asset);
|
|
|
|
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.id]),
|
|
undefined,
|
|
);
|
|
expect(mocks.ocr.getByAssetId).toHaveBeenCalledWith(asset.id);
|
|
});
|
|
|
|
it('should return empty array when no OCR data exists', async () => {
|
|
const asset = AssetFactory.from().exif().build();
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
|
mocks.ocr.getByAssetId.mockResolvedValue([]);
|
|
mocks.asset.getById.mockResolvedValue(asset);
|
|
await expect(sut.getOcr(authStub.admin, asset.id)).resolves.toEqual([]);
|
|
|
|
expect(mocks.ocr.getByAssetId).toHaveBeenCalledWith(asset.id);
|
|
});
|
|
});
|
|
|
|
describe('run', () => {
|
|
it('should run the refresh faces job', async () => {
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
|
|
|
await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_FACES });
|
|
|
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.AssetDetectFaces, data: { id: 'asset-1' } }]);
|
|
});
|
|
|
|
it('should run the refresh metadata job', async () => {
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
|
|
|
await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_METADATA });
|
|
|
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
|
{ name: JobName.AssetExtractMetadata, data: { id: 'asset-1' } },
|
|
]);
|
|
});
|
|
|
|
it('should run the refresh thumbnails job', async () => {
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
|
|
|
await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REGENERATE_THUMBNAIL });
|
|
|
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
|
{ name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1' } },
|
|
]);
|
|
});
|
|
|
|
it('should run the transcode video', async () => {
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
|
|
|
await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.TRANSCODE_VIDEO });
|
|
|
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.AssetEncodeVideo, data: { id: 'asset-1' } }]);
|
|
});
|
|
});
|
|
|
|
describe('getUserAssetsByDeviceId', () => {
|
|
it('get assets by device id', async () => {
|
|
const assets = [AssetFactory.create(), AssetFactory.create()];
|
|
|
|
mocks.asset.getAllByDeviceId.mockResolvedValue(assets.map((asset) => asset.deviceAssetId));
|
|
|
|
const deviceId = 'device-id';
|
|
const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId);
|
|
|
|
expect(result.length).toEqual(2);
|
|
expect(result).toEqual(assets.map((asset) => asset.deviceAssetId));
|
|
});
|
|
});
|
|
|
|
describe('upsertMetadata', () => {
|
|
it('should throw a bad request exception if duplicate keys are sent', async () => {
|
|
const asset = factory.asset();
|
|
const items = [
|
|
{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
|
|
{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
|
|
];
|
|
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
|
|
|
await expect(sut.upsertMetadata(authStub.admin, asset.id, { items })).rejects.toThrowError(
|
|
'Duplicate items are not allowed:',
|
|
);
|
|
|
|
expect(mocks.asset.upsertBulkMetadata).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('upsertBulkMetadata', () => {
|
|
it('should throw a bad request exception if duplicate keys are sent', async () => {
|
|
const asset = factory.asset();
|
|
const items = [
|
|
{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
|
|
{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
|
|
];
|
|
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
|
|
|
await expect(sut.upsertBulkMetadata(authStub.admin, { items })).rejects.toThrowError(
|
|
'Duplicate items are not allowed:',
|
|
);
|
|
|
|
expect(mocks.asset.upsertBulkMetadata).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('editAsset', () => {
|
|
it('should enforce crop first', async () => {
|
|
await expect(
|
|
sut.editAsset(authStub.admin, 'asset-1', {
|
|
edits: [
|
|
{
|
|
action: AssetEditAction.Rotate,
|
|
parameters: { angle: 90 },
|
|
},
|
|
{
|
|
action: AssetEditAction.Crop,
|
|
parameters: { x: 0, y: 0, width: 100, height: 100 },
|
|
},
|
|
],
|
|
}),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
|
|
expect(mocks.assetEdit.replaceAll).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|