1
0
forked from Cutlery/immich
immich-quadlet/server/src/immich/api-v1/asset/asset.service.spec.ts
Thomas f0302670d2
fix(server): add missing extensions and mime types (#3318)
Add extensions and mime types which were accidentally removed in #3197.

Fixes: #3300
2023-07-19 09:27:25 -05:00

467 lines
17 KiB
TypeScript

import { ICryptoRepository, IJobRepository, IStorageRepository, JobName } from '@app/domain';
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { BadRequestException } from '@nestjs/common';
import {
assetEntityStub,
authStub,
fileStub,
IAccessRepositoryMock,
newAccessRepositoryMock,
newCryptoRepositoryMock,
newJobRepositoryMock,
newStorageRepositoryMock,
} from '@test';
import { when } from 'jest-when';
import { QueryFailedError, Repository } from 'typeorm';
import { IAssetRepository } from './asset-repository';
import { AssetService } from './asset.service';
import { CreateAssetDto } from './dto/create-asset.dto';
import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
import { AssetRejectReason, AssetUploadAction } from './response-dto/asset-check-response.dto';
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
const _getCreateAssetDto = (): CreateAssetDto => {
const createAssetDto = new CreateAssetDto();
createAssetDto.deviceAssetId = 'deviceAssetId';
createAssetDto.deviceId = 'deviceId';
createAssetDto.fileCreatedAt = new Date('2022-06-19T23:41:36.910Z');
createAssetDto.fileModifiedAt = new Date('2022-06-19T23:41:36.910Z');
createAssetDto.isFavorite = false;
createAssetDto.isArchived = false;
createAssetDto.duration = '0:00:00.000000';
return createAssetDto;
};
const _getAsset_1 = () => {
const asset_1 = new AssetEntity();
asset_1.id = 'id_1';
asset_1.ownerId = 'user_id_1';
asset_1.deviceAssetId = 'device_asset_id_1';
asset_1.deviceId = 'device_id_1';
asset_1.type = AssetType.VIDEO;
asset_1.originalPath = 'fake_path/asset_1.jpeg';
asset_1.resizePath = '';
asset_1.fileModifiedAt = new Date('2022-06-19T23:41:36.910Z');
asset_1.fileCreatedAt = new Date('2022-06-19T23:41:36.910Z');
asset_1.updatedAt = new Date('2022-06-19T23:41:36.910Z');
asset_1.isFavorite = false;
asset_1.isArchived = false;
asset_1.webpPath = '';
asset_1.encodedVideoPath = '';
asset_1.duration = '0:00:00.000000';
asset_1.exifInfo = new ExifEntity();
asset_1.exifInfo.latitude = 49.533547;
asset_1.exifInfo.longitude = 10.703075;
return asset_1;
};
const _getAsset_2 = () => {
const asset_2 = new AssetEntity();
asset_2.id = 'id_2';
asset_2.ownerId = 'user_id_1';
asset_2.deviceAssetId = 'device_asset_id_2';
asset_2.deviceId = 'device_id_1';
asset_2.type = AssetType.VIDEO;
asset_2.originalPath = 'fake_path/asset_2.jpeg';
asset_2.resizePath = '';
asset_2.fileModifiedAt = new Date('2022-06-19T23:41:36.910Z');
asset_2.fileCreatedAt = new Date('2022-06-19T23:41:36.910Z');
asset_2.updatedAt = new Date('2022-06-19T23:41:36.910Z');
asset_2.isFavorite = false;
asset_2.isArchived = false;
asset_2.webpPath = '';
asset_2.encodedVideoPath = '';
asset_2.duration = '0:00:00.000000';
return asset_2;
};
const _getAssets = () => {
return [_getAsset_1(), _getAsset_2()];
};
const _getAssetCountByTimeBucket = (): AssetCountByTimeBucket[] => {
const result1 = new AssetCountByTimeBucket();
result1.count = 2;
result1.timeBucket = '2022-06-01T00:00:00.000Z';
const result2 = new AssetCountByTimeBucket();
result1.count = 5;
result1.timeBucket = '2022-07-01T00:00:00.000Z';
return [result1, result2];
};
describe('AssetService', () => {
let sut: AssetService;
let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
let accessMock: IAccessRepositoryMock;
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let jobMock: jest.Mocked<IJobRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
beforeEach(() => {
assetRepositoryMock = {
get: jest.fn(),
create: jest.fn(),
remove: jest.fn(),
update: jest.fn(),
getAllByUserId: jest.fn(),
getAllByDeviceId: jest.fn(),
getAssetCountByTimeBucket: jest.fn(),
getById: jest.fn(),
getDetectedObjectsByUserId: jest.fn(),
getLocationsByUserId: jest.fn(),
getSearchPropertiesByUserId: jest.fn(),
getAssetByTimeBucket: jest.fn(),
getAssetsByChecksums: jest.fn(),
getExistingAssets: jest.fn(),
getByOriginalPath: jest.fn(),
};
accessMock = newAccessRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock();
storageMock = newStorageRepositoryMock();
sut = new AssetService(accessMock, assetRepositoryMock, a, cryptoMock, jobMock, storageMock);
when(assetRepositoryMock.get)
.calledWith(assetEntityStub.livePhotoStillAsset.id)
.mockResolvedValue(assetEntityStub.livePhotoStillAsset);
when(assetRepositoryMock.get)
.calledWith(assetEntityStub.livePhotoMotionAsset.id)
.mockResolvedValue(assetEntityStub.livePhotoMotionAsset);
});
describe('uploadFile', () => {
it('should handle a file upload', async () => {
const assetEntity = _getAsset_1();
const file = {
originalPath: 'fake_path/asset_1.jpeg',
mimeType: 'image/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg',
};
const dto = _getCreateAssetDto();
assetRepositoryMock.create.mockResolvedValue(assetEntity);
await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: false, id: 'id_1' });
expect(assetRepositoryMock.create).toHaveBeenCalled();
});
it('should handle a duplicate', async () => {
const file = {
originalPath: 'fake_path/asset_1.jpeg',
mimeType: 'image/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg',
};
const dto = _getCreateAssetDto();
const error = new QueryFailedError('', [], '');
(error as any).constraint = 'UQ_userid_checksum';
assetRepositoryMock.create.mockRejectedValue(error);
assetRepositoryMock.getAssetsByChecksums.mockResolvedValue([_getAsset_1()]);
await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: true, id: 'id_1' });
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.DELETE_FILES,
data: { files: ['fake_path/asset_1.jpeg', undefined, undefined] },
});
expect(storageMock.moveFile).not.toHaveBeenCalled();
});
it('should handle a live photo', async () => {
const dto = _getCreateAssetDto();
const error = new QueryFailedError('', [], '');
(error as any).constraint = 'UQ_userid_checksum';
assetRepositoryMock.create.mockResolvedValueOnce(assetEntityStub.livePhotoMotionAsset);
assetRepositoryMock.create.mockResolvedValueOnce(assetEntityStub.livePhotoStillAsset);
await expect(
sut.uploadFile(authStub.user1, dto, fileStub.livePhotoStill, fileStub.livePhotoMotion),
).resolves.toEqual({
duplicate: false,
id: 'live-photo-still-asset',
});
expect(jobMock.queue.mock.calls).toEqual([
[
{
name: JobName.METADATA_EXTRACTION,
data: { id: assetEntityStub.livePhotoMotionAsset.id, source: 'upload' },
},
],
[{ name: JobName.METADATA_EXTRACTION, data: { id: assetEntityStub.livePhotoStillAsset.id, source: 'upload' } }],
]);
});
});
it('get assets by device id', async () => {
const assets = _getAssets();
assetRepositoryMock.getAllByDeviceId.mockImplementation(() =>
Promise.resolve<string[]>(Array.from(assets.map((asset) => asset.deviceAssetId))),
);
const deviceId = 'device_id_1';
const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId);
expect(result.length).toEqual(2);
expect(result).toEqual(assets.map((asset) => asset.deviceAssetId));
});
it('get assets count by time bucket', async () => {
const assetCountByTimeBucket = _getAssetCountByTimeBucket();
assetRepositoryMock.getAssetCountByTimeBucket.mockImplementation(() =>
Promise.resolve<AssetCountByTimeBucket[]>(assetCountByTimeBucket),
);
const result = await sut.getAssetCountByTimeBucket(authStub.user1, {
timeGroup: TimeGroupEnum.Month,
});
expect(result.totalCount).toEqual(assetCountByTimeBucket.reduce((a, b) => a + b.count, 0));
expect(result.buckets.length).toEqual(2);
});
describe('deleteAll', () => {
it('should return failed status when an asset is missing', async () => {
assetRepositoryMock.get.mockResolvedValue(null);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([
{ id: 'asset1', status: 'FAILED' },
]);
expect(jobMock.queue).not.toHaveBeenCalled();
});
it('should return failed status a delete fails', async () => {
assetRepositoryMock.get.mockResolvedValue({ id: 'asset1' } as AssetEntity);
assetRepositoryMock.remove.mockRejectedValue('delete failed');
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([
{ id: 'asset1', status: 'FAILED' },
]);
expect(jobMock.queue).not.toHaveBeenCalled();
});
it('should delete a live photo', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.deleteAll(authStub.user1, { ids: [assetEntityStub.livePhotoStillAsset.id] })).resolves.toEqual([
{ id: assetEntityStub.livePhotoStillAsset.id, status: 'SUCCESS' },
{ id: assetEntityStub.livePhotoMotionAsset.id, status: 'SUCCESS' },
]);
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.DELETE_FILES,
data: {
files: [
'fake_path/asset_1.jpeg',
undefined,
undefined,
undefined,
undefined,
'fake_path/asset_1.mp4',
undefined,
undefined,
undefined,
undefined,
],
},
});
});
it('should delete a batch of assets', async () => {
const asset1 = {
id: 'asset1',
originalPath: 'original-path-1',
resizePath: 'resize-path-1',
webpPath: 'web-path-1',
};
const asset2 = {
id: 'asset2',
originalPath: 'original-path-2',
resizePath: 'resize-path-2',
webpPath: 'web-path-2',
encodedVideoPath: 'encoded-video-path-2',
};
when(assetRepositoryMock.get)
.calledWith(asset1.id)
.mockResolvedValue(asset1 as AssetEntity);
when(assetRepositoryMock.get)
.calledWith(asset2.id)
.mockResolvedValue(asset2 as AssetEntity);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'] })).resolves.toEqual([
{ id: 'asset1', status: 'SUCCESS' },
{ id: 'asset2', status: 'SUCCESS' },
]);
expect(jobMock.queue.mock.calls).toEqual([
[{ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: ['asset1'] } }],
[{ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: ['asset2'] } }],
[
{
name: JobName.DELETE_FILES,
data: {
files: [
'original-path-1',
'web-path-1',
'resize-path-1',
undefined,
undefined,
'original-path-2',
'web-path-2',
'resize-path-2',
'encoded-video-path-2',
undefined,
],
},
},
],
]);
});
});
describe('bulkUploadCheck', () => {
it('should accept hex and base64 checksums', async () => {
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
const file2 = Buffer.from('53be335e99f18a66ff12e9a901c7a6171dd76573', 'hex');
assetRepositoryMock.getAssetsByChecksums.mockResolvedValue([
{ id: 'asset-1', checksum: file1 },
{ id: 'asset-2', checksum: file2 },
]);
await expect(
sut.bulkUploadCheck(authStub.admin, {
assets: [
{ id: '1', checksum: file1.toString('hex') },
{ id: '2', checksum: file2.toString('base64') },
],
}),
).resolves.toEqual({
results: [
{ id: '1', assetId: 'asset-1', action: AssetUploadAction.REJECT, reason: AssetRejectReason.DUPLICATE },
{ id: '2', assetId: 'asset-2', action: AssetUploadAction.REJECT, reason: AssetRejectReason.DUPLICATE },
],
});
expect(assetRepositoryMock.getAssetsByChecksums).toHaveBeenCalledWith(authStub.admin.id, [file1, file2]);
});
});
describe('importFile', () => {
it('should handle a file import', async () => {
assetRepositoryMock.create.mockResolvedValue(assetEntityStub.image);
storageMock.checkFileExists.mockResolvedValue(true);
await expect(
sut.importFile(authStub.external1, {
..._getCreateAssetDto(),
assetPath: '/data/user1/fake_path/asset_1.jpeg',
isReadOnly: true,
}),
).resolves.toEqual({ duplicate: false, id: 'asset-id' });
expect(assetRepositoryMock.create).toHaveBeenCalled();
});
it('should handle a duplicate if originalPath already exists', async () => {
const error = new QueryFailedError('', [], '');
(error as any).constraint = 'UQ_userid_checksum';
assetRepositoryMock.create.mockRejectedValue(error);
assetRepositoryMock.getAssetsByChecksums.mockResolvedValue([assetEntityStub.image]);
storageMock.checkFileExists.mockResolvedValue(true);
cryptoMock.hashFile.mockResolvedValue(Buffer.from('file hash', 'utf8'));
await expect(
sut.importFile(authStub.external1, {
..._getCreateAssetDto(),
assetPath: '/data/user1/fake_path/asset_1.jpeg',
isReadOnly: true,
}),
).resolves.toEqual({ duplicate: true, id: 'asset-id' });
expect(assetRepositoryMock.create).toHaveBeenCalled();
});
});
describe('getAssetById', () => {
it('should allow owner access', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
assetRepositoryMock.getById.mockResolvedValue(assetEntityStub.image);
await sut.getAssetById(authStub.admin, assetEntityStub.image.id);
expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
});
it('should allow shared link access', async () => {
accessMock.asset.hasSharedLinkAccess.mockResolvedValue(true);
assetRepositoryMock.getById.mockResolvedValue(assetEntityStub.image);
await sut.getAssetById(authStub.adminSharedLink, assetEntityStub.image.id);
expect(accessMock.asset.hasSharedLinkAccess).toHaveBeenCalledWith(
authStub.adminSharedLink.sharedLinkId,
assetEntityStub.image.id,
);
});
it('should allow partner sharing access', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
accessMock.asset.hasPartnerAccess.mockResolvedValue(true);
assetRepositoryMock.getById.mockResolvedValue(assetEntityStub.image);
await sut.getAssetById(authStub.admin, assetEntityStub.image.id);
expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
});
it('should allow shared album access', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
accessMock.asset.hasPartnerAccess.mockResolvedValue(false);
accessMock.asset.hasAlbumAccess.mockResolvedValue(true);
assetRepositoryMock.getById.mockResolvedValue(assetEntityStub.image);
await sut.getAssetById(authStub.admin, assetEntityStub.image.id);
expect(accessMock.asset.hasAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
});
it('should throw an error for no access', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
accessMock.asset.hasPartnerAccess.mockResolvedValue(false);
accessMock.asset.hasSharedLinkAccess.mockResolvedValue(false);
accessMock.asset.hasAlbumAccess.mockResolvedValue(false);
await expect(sut.getAssetById(authStub.admin, assetEntityStub.image.id)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(assetRepositoryMock.getById).not.toHaveBeenCalled();
});
it('should throw an error for an invalid shared link', async () => {
accessMock.asset.hasSharedLinkAccess.mockResolvedValue(false);
await expect(sut.getAssetById(authStub.adminSharedLink, assetEntityStub.image.id)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(accessMock.asset.hasOwnerAccess).not.toHaveBeenCalled();
expect(assetRepositoryMock.getById).not.toHaveBeenCalled();
});
});
});